From 0feedc05b95ed4700ebb4306c0d7c16842282fc4 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:07:03 +0530 Subject: [PATCH 01/45] Harden Pi-hole API proxy and add cached health data --- proxy.py | 530 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 341 insertions(+), 189 deletions(-) diff --git a/proxy.py b/proxy.py index c4084e4..906bccf 100644 --- a/proxy.py +++ b/proxy.py @@ -1,42 +1,43 @@ from flask import Flask, jsonify, send_from_directory, request, Blueprint from markupsafe import escape import requests -from flask_cors import CORS import os import json import concurrent.futures import threading +import time from urllib.parse import urlparse -# Constants NO_PASSWORD = "NO_PASSWORD" -# Define blocked status codes according to Pi-hole API documentation +DEFAULT_REFRESH_INTERVAL = 5000 +DEFAULT_CACHE_TTL = 1000 +DEFAULT_REQUEST_TIMEOUT = 10 +SLOW_RESPONSE_MS = 500 + BLOCKED_STATUSES = { - 'GRAVITY', 'REGEX', 'DENYLIST', + 'GRAVITY', 'REGEX', 'DENYLIST', 'EXTERNAL_BLOCKED_IP', 'EXTERNAL_BLOCKED_NULL', 'EXTERNAL_BLOCKED_NXRA', 'GRAVITY_CNAME', 'REGEX_CNAME', 'DENYLIST_CNAME', 'DBBUSY', 'SPECIAL_DOMAIN', 'EXTERNAL_BLOCKED_EDE15' } app = Flask(__name__) -CORS(app) - - APP_ROOT = os.path.dirname(os.path.realpath(__file__)) -with open(os.path.join(APP_ROOT, 'config.json')) as f: - config = json.load(f) +def _load_json(filename): + with open(os.path.join(APP_ROOT, filename), encoding='utf-8') as handle: + return json.load(handle) -with open(os.path.join(APP_ROOT, 'manifest.json')) as f: - manifest_data = json.load(f) -with open(os.path.join(APP_ROOT, 'index.html')) as f: - index_template = f.read() +config = _load_json('config.json') +manifest_data = _load_json('manifest.json') -with open(os.path.join(APP_ROOT, 'sw.js')) as f: - sw_template = f.read() +with open(os.path.join(APP_ROOT, 'index.html'), encoding='utf-8') as handle: + index_template = handle.read() +with open(os.path.join(APP_ROOT, 'sw.js'), encoding='utf-8') as handle: + sw_template = handle.read() base_path_config = config.get('base_path', '/') url_prefix = base_path_config @@ -50,282 +51,433 @@ bp = Blueprint('pi-dash', __name__) pihole_sessions = {} _sessions_lock = threading.Lock() +_cache_lock = threading.Lock() +_stats_refresh_lock = threading.Lock() +_queries_refresh_lock = threading.Lock() +_stats_cache = {'time': 0.0, 'data': None} +_queries_cache = {} + + +def _positive_int(value, default, minimum=100): + try: + parsed = int(value) + return parsed if parsed >= minimum else default + except (TypeError, ValueError): + return default + + +def get_refresh_interval(): + return _positive_int(config.get('refresh_interval'), DEFAULT_REFRESH_INTERVAL) + + +def get_queries_refresh_interval(): + # Backward compatibility: old configs only have refresh_interval, so query + # polling keeps the exact same cadence until this new option is configured. + return _positive_int(config.get('queries_refresh_interval'), get_refresh_interval()) + + +def get_cache_ttl(): + return _positive_int(config.get('cache_ttl'), DEFAULT_CACHE_TTL, minimum=0) -def authenticate_and_get_sid(address, password): +def resolve_secret(value): + """Resolve ${ENV_VAR} secrets while leaving normal config values unchanged.""" + if not isinstance(value, str): + return value + if value.startswith('${') and value.endswith('}') and len(value) > 3: + env_name = value[2:-1] + return os.environ.get(env_name, value) + return value + + +def get_verify_setting(pihole_config): + """Return a requests-compatible TLS verification setting. + + verify_ssl is optional and defaults to False to preserve existing installs. + It may be true/false or a path to a CA bundle. + """ + verify = pihole_config.get('verify_ssl', False) + if isinstance(verify, str): + lowered = verify.strip().lower() + if lowered == 'true': + return True + if lowered == 'false': + return False + return resolve_secret(verify) + return bool(verify) + + +def get_headers(sid): + return {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} + + +def authenticate_and_get_sid(pihole_config): + address = pihole_config['address'].rstrip('/') + password = resolve_secret(pihole_config.get('password', '')) auth_url = f"{address}/api/auth" payload = {"password": password} + verify = get_verify_setting(pihole_config) + try: - response = requests.post(auth_url, json=payload, timeout=10, verify=False) + response = requests.post( + auth_url, + json=payload, + timeout=DEFAULT_REQUEST_TIMEOUT, + verify=verify, + ) if response.status_code == 200: data = response.json() - new_sid = data.get("session", {}).get("sid") + new_sid = data.get('session', {}).get('sid') if new_sid: - print(f"Successfully authenticated with {address} and got new SID.") return new_sid - elif data.get("session", {}).get("message") == "no password set": - print(f"Pi-hole at {address} has no password set.") + if data.get('session', {}).get('message') == 'no password set': return NO_PASSWORD - else: - print(f"Authentication with {address} returned 200 OK but no SID found. Response: {data}") - return None - elif response.status_code == 401: - print(f"Authentication failed for {address}: Incorrect Password (401 Unauthorized). Please check your password in config.json.") + print(f"Authentication with {address} returned 200 but no SID was found.") return None - else: - print(f"Authentication failed for {address} with status code {response.status_code}. Response: {response.text}") + if response.status_code == 401: + print(f"Authentication failed for {address}: incorrect password.") return None - except requests.exceptions.RequestException as e: - print(f"A network error occurred during authentication with {address}: {e}") + print(f"Authentication failed for {address} with HTTP {response.status_code}.") + return None + except requests.exceptions.RequestException as exc: + print(f"Network error while authenticating with {address}: {exc}") return None + +def get_sid(pihole_config, force=False): + name = pihole_config['name'] + if force: + with _sessions_lock: + pihole_sessions.pop(name, None) + + with _sessions_lock: + sid = pihole_sessions.get(name) + if sid: + return sid + + sid = authenticate_and_get_sid(pihole_config) + if sid: + with _sessions_lock: + pihole_sessions[name] = sid + return sid + + +def pihole_get(pihole_config, api_path, params=None): + address = pihole_config['address'].rstrip('/') + sid = get_sid(pihole_config) + if not sid: + raise RuntimeError('authentication failed') + + request_args = { + 'headers': get_headers(sid), + 'params': params, + 'timeout': DEFAULT_REQUEST_TIMEOUT, + 'verify': get_verify_setting(pihole_config), + } + response = requests.get(f"{address}{api_path}", **request_args) + + if response.status_code == 401 and sid != NO_PASSWORD: + sid = get_sid(pihole_config, force=True) + if not sid: + raise RuntimeError('re-authentication failed') + request_args['headers'] = get_headers(sid) + response = requests.get(f"{address}{api_path}", **request_args) + + response.raise_for_status() + return response + + # -- Frontend Routes -- @bp.route('/') def index(): - icon_url = '' if manifest_data.get('icons'): icon_url = manifest_data['icons'][0].get('src', '') - - - base_tag = f'' + + base_tag = f'' temp_html = index_template.replace('', f'\n {base_tag}') - final_html = temp_html.replace('{{ICON_URL}}', escape(icon_url)) - - return final_html + return temp_html.replace('{{ICON_URL}}', escape(icon_url)) + @bp.route('/manifest.json') def serve_manifest(): - manifest_copy = manifest_data.copy() manifest_copy['start_url'] = html_base return jsonify(manifest_copy) + @bp.route('/sw.js') def serve_sw(): - sw_content = sw_template.replace('{{CACHE_URL}}', html_base) return sw_content, 200, {'Content-Type': 'application/javascript'} + @bp.route('/css/') def send_css(path): return send_from_directory(os.path.join(APP_ROOT, 'css'), path) + @bp.route('/js/') def send_js(path): return send_from_directory(os.path.join(APP_ROOT, 'js'), path) + @bp.route('/favicon.ico') def favicon(): return '', 204 -# -- API Routes -- + +# -- API helpers -- +def get_enabled_piholes(): + return [p for p in config.get('piholes', []) if p.get('enabled', True)] + + def get_filtered_config(): - piholes_filtered = [] - for p in config["piholes"]: - if not p.get("enabled", True): - continue + for pihole in get_enabled_piholes(): item = { - "name": p["name"], - "enabled": p["enabled"], - "link": p.get("link", False) + 'name': pihole['name'], + 'enabled': True, + 'link': pihole.get('link', False), } - if item["link"]: - - item["address"] = p["address"] + if item['link']: + item['address'] = pihole['address'] piholes_filtered.append(item) + return { - "refresh_interval": config.get("refresh_interval", 5000), - "piholes": piholes_filtered, - "show_queries": config.get("show_queries", False) + 'refresh_interval': get_refresh_interval(), + 'queries_refresh_interval': get_queries_refresh_interval(), + 'show_queries': config.get('show_queries', False), + 'show_network_summary': config.get('show_network_summary', True), + 'show_trends': config.get('show_trends', True), + 'piholes': piholes_filtered, } -def get_pihole_data(address, sid): - url = f"{address}/api/stats/summary" - headers = {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} - return requests.get(url, headers=headers, timeout=10, verify=False) -def fetch_all_pihole_data(): - enabled_piholes = [p for p in config['piholes'] if p.get('enabled', True)] - results = {} - - def fetch_single_pihole(pihole_config): - name = pihole_config['name'] - address = pihole_config['address'] - password = pihole_config['password'] - - - with _sessions_lock: - sid = pihole_sessions.get(name) - if not sid: - sid = authenticate_and_get_sid(address, password) - if not sid: - return name, {"error": f"Authentication failed for Pi-hole '{name}'"} - with _sessions_lock: - pihole_sessions[name] = sid - +def fetch_single_pihole(pihole_config): + name = pihole_config['name'] + start = time.perf_counter() + try: + response = pihole_get(pihole_config, '/api/stats/summary') + latency_ms = round((time.perf_counter() - start) * 1000, 1) + data = response.json() + + # Blocking status is useful health information but must not make the + # whole card fail if an older/changed Pi-hole API does not provide it. + blocking = None try: - response = get_pihole_data(address, sid) - if response.status_code == 401 and sid != NO_PASSWORD: - - print(f"SID for Pi-hole '{name}' expired. Re-authenticating...") - with _sessions_lock: - pihole_sessions.pop(name, None) - sid = authenticate_and_get_sid(address, password) - if not sid: - return name, {"error": f"Re-authentication failed for Pi-hole '{name}'"} - with _sessions_lock: - pihole_sessions[name] = sid - response = get_pihole_data(address, sid) - - response.raise_for_status() - return name, response.json() - except requests.exceptions.RequestException as e: - return name, {"error": str(e)} - - - with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(enabled_piholes), 10)) as executor: - future_to_pihole = { - executor.submit(fetch_single_pihole, pihole): pihole - for pihole in enabled_piholes + blocking_response = pihole_get(pihole_config, '/api/dns/blocking') + blocking = blocking_response.json().get('blocking') + except (requests.exceptions.RequestException, RuntimeError, ValueError): + pass + + data['_pi_dash'] = { + 'latency_ms': latency_ms, + 'blocking': blocking, + 'health': 'slow' if latency_ms >= SLOW_RESPONSE_MS else 'healthy', } - - for future in concurrent.futures.as_completed(future_to_pihole): + return name, data + except RuntimeError as exc: + return name, {'error': str(exc), '_pi_dash': {'health': 'auth_error'}} + except requests.exceptions.RequestException as exc: + return name, {'error': str(exc), '_pi_dash': {'health': 'unreachable'}} + except ValueError as exc: + return name, {'error': f'Invalid JSON response: {exc}', '_pi_dash': {'health': 'unreachable'}} + + +def _fetch_all_pihole_data_uncached(): + enabled_piholes = get_enabled_piholes() + if not enabled_piholes: + return {} + + results = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(enabled_piholes), 10)) as executor: + futures = [executor.submit(fetch_single_pihole, pihole) for pihole in enabled_piholes] + for future in concurrent.futures.as_completed(futures): name, data = future.result() results[name] = data - return results -def fetch_recent_queries(length=50): - """Fetch recent queries from all enabled Pi-holes. - Returns a dict mapping pihole name to list of queries. Each query is - represented minimally with keys: domain, status (blocked or allowed) - and optionally type/time if available. - """ - enabled_piholes = [p for p in config['piholes'] if p.get('enabled', True)] +def fetch_all_pihole_data(force=False): + ttl_seconds = get_cache_ttl() / 1000.0 + now = time.monotonic() + with _cache_lock: + if not force and _stats_cache['data'] is not None and now - _stats_cache['time'] < ttl_seconds: + return _stats_cache['data'] + + with _stats_refresh_lock: + now = time.monotonic() + with _cache_lock: + if not force and _stats_cache['data'] is not None and now - _stats_cache['time'] < ttl_seconds: + return _stats_cache['data'] + + data = _fetch_all_pihole_data_uncached() + with _cache_lock: + _stats_cache['time'] = time.monotonic() + _stats_cache['data'] = data + return data + + +def _fetch_recent_queries_uncached(length=50): + enabled_piholes = get_enabled_piholes() + if not enabled_piholes: + return {} + results = {} - - # Extract all Pi-hole hostnames to filter cross-Pi-hole queries pihole_hostnames = set() - for p in enabled_piholes: - parsed = urlparse(p['address']) - hostname = parsed.hostname or parsed.netloc.split(':')[0] if parsed.netloc else None + for pihole in enabled_piholes: + parsed = urlparse(pihole['address']) + hostname = parsed.hostname or (parsed.netloc.split(':')[0] if parsed.netloc else None) if hostname: pihole_hostnames.add(hostname.lower()) def fetch_queries_for_pihole(pihole_config): name = pihole_config['name'] - address = pihole_config['address'] - password = pihole_config['password'] - - with _sessions_lock: - sid = pihole_sessions.get(name) - if not sid: - sid = authenticate_and_get_sid(address, password) - if not sid: - return name, [] - with _sessions_lock: - pihole_sessions[name] = sid - headers = {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} - url = f"{address}/api/queries?length={length}" try: - r = requests.get(url, headers=headers, timeout=10, verify=False) - if r.status_code == 401 and sid != NO_PASSWORD: - # attempt re-auth - with _sessions_lock: - pihole_sessions.pop(name, None) - sid = authenticate_and_get_sid(address, password) - if not sid: - return name, [] - with _sessions_lock: - pihole_sessions[name] = sid - headers = {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} - r = requests.get(url, headers=headers, timeout=10, verify=False) - r.raise_for_status() - data = r.json() - + response = pihole_get(pihole_config, '/api/queries', params={'length': length}) + data = response.json() normalized = [] - for q in data.get('queries', [])[:length]: - original_domain = q.get('domain', '') + for query in data.get('queries', [])[:length]: + original_domain = query.get('domain', '') domain = original_domain.lower().strip() - status = (q.get('status') or '').upper() - ts = q.get('time') or q.get('timestamp') - upstream = q.get('upstream', '') - qid = q.get('id') - - # Skip queries to ANY Pi-hole hostname (not just this one) if domain in pihole_hostnames: continue - - # Check if status matches any blocked status - blocked = status in BLOCKED_STATUSES - + + status = (query.get('status') or '').upper() + timestamp = query.get('time') or query.get('timestamp') normalized.append({ - 'id': qid, - 'domain': original_domain, # preserve original casing for display - 'blocked': blocked, - 'time': ts, - 'timestamp': ts, # legacy compatibility - 'upstream': upstream + 'id': query.get('id'), + 'domain': original_domain, + 'blocked': status in BLOCKED_STATUSES, + 'time': timestamp, + 'timestamp': timestamp, + 'upstream': query.get('upstream', ''), }) - return name, normalized - except requests.exceptions.RequestException: + except (requests.exceptions.RequestException, RuntimeError, ValueError): return name, [] with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(enabled_piholes), 10)) as executor: - future_to_pihole = {executor.submit(fetch_queries_for_pihole, p): p for p in enabled_piholes} - for future in concurrent.futures.as_completed(future_to_pihole): + futures = [executor.submit(fetch_queries_for_pihole, pihole) for pihole in enabled_piholes] + for future in concurrent.futures.as_completed(futures): name, data = future.result() results[name] = data return results + +def fetch_recent_queries(length=50, force=False): + ttl_seconds = get_cache_ttl() / 1000.0 + now = time.monotonic() + with _cache_lock: + cached = _queries_cache.get(length) + if not force and cached and now - cached['time'] < ttl_seconds: + return cached['data'] + + with _queries_refresh_lock: + now = time.monotonic() + with _cache_lock: + cached = _queries_cache.get(length) + if not force and cached and now - cached['time'] < ttl_seconds: + return cached['data'] + + data = _fetch_recent_queries_uncached(length) + with _cache_lock: + _queries_cache[length] = {'time': time.monotonic(), 'data': data} + return data + + +def build_network_summary(pihole_data): + total = blocked = cached = forwarded = 0 + healthy = slow = offline = 0 + contributing = 0 + + for data in pihole_data.values(): + if data.get('error') or not data.get('queries'): + offline += 1 + continue + contributing += 1 + queries = data.get('queries', {}) + total += int(queries.get('total') or 0) + blocked += int(queries.get('blocked') or 0) + cached += int(queries.get('cached') or 0) + forwarded += int(queries.get('forwarded') or 0) + if data.get('_pi_dash', {}).get('health') == 'slow': + slow += 1 + else: + healthy += 1 + + return { + 'total_queries': total, + 'blocked_queries': blocked, + 'percent_blocked': round((blocked / total * 100), 1) if total else 0, + 'cached_queries': cached, + 'forwarded_queries': forwarded, + 'instances': len(pihole_data), + 'contributing_instances': contributing, + 'healthy_instances': healthy, + 'slow_instances': slow, + 'offline_instances': offline, + } + + +# -- API Routes -- @bp.route('/init') def init(): try: filtered_config = get_filtered_config() pihole_data = fetch_all_pihole_data() response_data = { - "config": filtered_config, - "data": pihole_data + 'config': filtered_config, + 'data': pihole_data, + 'summary': build_network_summary(pihole_data), } - if config.get("show_queries", False): - response_data["queries"] = fetch_recent_queries(length=50) + if config.get('show_queries', False): + response_data['queries'] = fetch_recent_queries(length=50) return jsonify(response_data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + @bp.route('/data') def data(): try: pihole_data = fetch_all_pihole_data() - - + # Preserve the legacy bare-object response unless callers explicitly + # request the richer payload. + include_summary = request.args.get('include_summary', 'false').lower() == 'true' include_queries = request.args.get('include_queries', 'false').lower() == 'true' + if not include_summary and not include_queries: + return jsonify(pihole_data) + + response_data = {'stats': pihole_data} + if include_summary: + response_data['summary'] = build_network_summary(pihole_data) if include_queries: - length = int(request.args.get('length', 50)) - length = max(1, min(length, 200)) - queries_data = fetch_recent_queries(length=length) - return jsonify({ - "stats": pihole_data, - "queries": queries_data - }) - - return jsonify(pihole_data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + length = max(1, min(int(request.args.get('length', 50)), 200)) + response_data['queries'] = fetch_recent_queries(length=length) + return jsonify(response_data) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + @bp.route('/queries') def queries(): try: - length = int(request.args.get('length', 50)) - length = max(1, min(length, 200)) # clamp - queries_data = fetch_recent_queries(length=length) - return jsonify(queries_data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + length = max(1, min(int(request.args.get('length', 50)), 200)) + return jsonify(fetch_recent_queries(length=length)) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@bp.route('/health') +def health(): + enabled = get_enabled_piholes() + return jsonify({ + 'status': 'ok', + 'enabled_piholes': len(enabled), + 'cache_ttl_ms': get_cache_ttl(), + }) app.register_blueprint(bp, url_prefix=url_prefix) From efc02c7461493a6184dc250156ef1e1e2ba5cfec Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:22:13 +0530 Subject: [PATCH 02/45] Refine cache behavior and blocking status polling --- proxy.py | 579 +++++++++++++++---------------------------------------- 1 file changed, 154 insertions(+), 425 deletions(-) diff --git a/proxy.py b/proxy.py index 906bccf..cf97ac2 100644 --- a/proxy.py +++ b/proxy.py @@ -1,486 +1,215 @@ from flask import Flask, jsonify, send_from_directory, request, Blueprint from markupsafe import escape import requests -import os -import json -import concurrent.futures -import threading -import time +import os, json, concurrent.futures, threading, time from urllib.parse import urlparse NO_PASSWORD = "NO_PASSWORD" DEFAULT_REFRESH_INTERVAL = 5000 -DEFAULT_CACHE_TTL = 1000 DEFAULT_REQUEST_TIMEOUT = 10 +DEFAULT_CACHE_TTL = 1000 +DEFAULT_BLOCKING_TTL = 30000 SLOW_RESPONSE_MS = 500 - -BLOCKED_STATUSES = { - 'GRAVITY', 'REGEX', 'DENYLIST', - 'EXTERNAL_BLOCKED_IP', 'EXTERNAL_BLOCKED_NULL', 'EXTERNAL_BLOCKED_NXRA', - 'GRAVITY_CNAME', 'REGEX_CNAME', 'DENYLIST_CNAME', - 'DBBUSY', 'SPECIAL_DOMAIN', 'EXTERNAL_BLOCKED_EDE15' -} +BLOCKED_STATUSES = {'GRAVITY','REGEX','DENYLIST','EXTERNAL_BLOCKED_IP','EXTERNAL_BLOCKED_NULL','EXTERNAL_BLOCKED_NXRA','GRAVITY_CNAME','REGEX_CNAME','DENYLIST_CNAME','DBBUSY','SPECIAL_DOMAIN','EXTERNAL_BLOCKED_EDE15'} app = Flask(__name__) APP_ROOT = os.path.dirname(os.path.realpath(__file__)) - -def _load_json(filename): - with open(os.path.join(APP_ROOT, filename), encoding='utf-8') as handle: - return json.load(handle) - - +def _load_json(name): + with open(os.path.join(APP_ROOT, name), encoding='utf-8') as f: return json.load(f) config = _load_json('config.json') manifest_data = _load_json('manifest.json') +with open(os.path.join(APP_ROOT,'index.html'),encoding='utf-8') as f: index_template=f.read() +with open(os.path.join(APP_ROOT,'sw.js'),encoding='utf-8') as f: sw_template=f.read() -with open(os.path.join(APP_ROOT, 'index.html'), encoding='utf-8') as handle: - index_template = handle.read() +base_path_config=config.get('base_path','/') +url_prefix=base_path_config[:-1] if base_path_config!='/' and base_path_config.endswith('/') else base_path_config +html_base=base_path_config if base_path_config.endswith('/') else base_path_config+'/' +bp=Blueprint('pi-dash',__name__) +pihole_sessions={}; _sessions_lock=threading.Lock(); _cache_lock=threading.Lock(); _stats_refresh_lock=threading.Lock(); _queries_refresh_lock=threading.Lock(); _blocking_lock=threading.Lock() +_stats_cache={'time':0.0,'data':None}; _queries_cache={}; _blocking_cache={} -with open(os.path.join(APP_ROOT, 'sw.js'), encoding='utf-8') as handle: - sw_template = handle.read() - -base_path_config = config.get('base_path', '/') -url_prefix = base_path_config -if url_prefix != '/' and url_prefix.endswith('/'): - url_prefix = url_prefix[:-1] - -html_base = base_path_config -if not html_base.endswith('/'): - html_base += '/' - -bp = Blueprint('pi-dash', __name__) -pihole_sessions = {} -_sessions_lock = threading.Lock() -_cache_lock = threading.Lock() -_stats_refresh_lock = threading.Lock() -_queries_refresh_lock = threading.Lock() -_stats_cache = {'time': 0.0, 'data': None} -_queries_cache = {} - - -def _positive_int(value, default, minimum=100): +def _int(value, default, minimum=100): try: - parsed = int(value) - return parsed if parsed >= minimum else default - except (TypeError, ValueError): - return default - - -def get_refresh_interval(): - return _positive_int(config.get('refresh_interval'), DEFAULT_REFRESH_INTERVAL) - - -def get_queries_refresh_interval(): - # Backward compatibility: old configs only have refresh_interval, so query - # polling keeps the exact same cadence until this new option is configured. - return _positive_int(config.get('queries_refresh_interval'), get_refresh_interval()) - + value=int(value); return value if value>=minimum else default + except (TypeError,ValueError): return default +def get_refresh_interval(): return _int(config.get('refresh_interval'),DEFAULT_REFRESH_INTERVAL) +def get_queries_refresh_interval(): return _int(config.get('queries_refresh_interval'),get_refresh_interval()) def get_cache_ttl(): - return _positive_int(config.get('cache_ttl'), DEFAULT_CACHE_TTL, minimum=0) - - + if 'cache_ttl' in config: return _int(config.get('cache_ttl'),DEFAULT_CACHE_TTL,0) + return min(DEFAULT_CACHE_TTL,max(100,min(get_refresh_interval(),get_queries_refresh_interval())//2)) def resolve_secret(value): - """Resolve ${ENV_VAR} secrets while leaving normal config values unchanged.""" - if not isinstance(value, str): - return value - if value.startswith('${') and value.endswith('}') and len(value) > 3: - env_name = value[2:-1] - return os.environ.get(env_name, value) + if isinstance(value,str) and value.startswith('${') and value.endswith('}'): + return os.environ.get(value[2:-1],value) return value - - -def get_verify_setting(pihole_config): - """Return a requests-compatible TLS verification setting. - - verify_ssl is optional and defaults to False to preserve existing installs. - It may be true/false or a path to a CA bundle. - """ - verify = pihole_config.get('verify_ssl', False) - if isinstance(verify, str): - lowered = verify.strip().lower() - if lowered == 'true': - return True - if lowered == 'false': - return False - return resolve_secret(verify) - return bool(verify) - - -def get_headers(sid): - return {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} - - -def authenticate_and_get_sid(pihole_config): - address = pihole_config['address'].rstrip('/') - password = resolve_secret(pihole_config.get('password', '')) - auth_url = f"{address}/api/auth" - payload = {"password": password} - verify = get_verify_setting(pihole_config) - +def get_verify_setting(p): + v=p.get('verify_ssl',False) + if isinstance(v,str): + if v.lower()=='true': return True + if v.lower()=='false': return False + return resolve_secret(v) + return bool(v) +def headers(sid): return {} if sid==NO_PASSWORD else {'X-FTL-SID':sid} + +def authenticate_and_get_sid(p): + address=p['address'].rstrip('/') try: - response = requests.post( - auth_url, - json=payload, - timeout=DEFAULT_REQUEST_TIMEOUT, - verify=verify, - ) - if response.status_code == 200: - data = response.json() - new_sid = data.get('session', {}).get('sid') - if new_sid: - return new_sid - if data.get('session', {}).get('message') == 'no password set': - return NO_PASSWORD - print(f"Authentication with {address} returned 200 but no SID was found.") - return None - if response.status_code == 401: - print(f"Authentication failed for {address}: incorrect password.") - return None - print(f"Authentication failed for {address} with HTTP {response.status_code}.") - return None - except requests.exceptions.RequestException as exc: - print(f"Network error while authenticating with {address}: {exc}") + r=requests.post(address+'/api/auth',json={'password':resolve_secret(p.get('password',''))},timeout=DEFAULT_REQUEST_TIMEOUT,verify=get_verify_setting(p)) + if r.status_code==200: + d=r.json(); sid=d.get('session',{}).get('sid') + if sid: return sid + if d.get('session',{}).get('message')=='no password set': return NO_PASSWORD return None + except requests.exceptions.RequestException: return None - -def get_sid(pihole_config, force=False): - name = pihole_config['name'] - if force: - with _sessions_lock: - pihole_sessions.pop(name, None) - +def get_sid(p,force=False): + name=p['name'] with _sessions_lock: - sid = pihole_sessions.get(name) - if sid: - return sid - - sid = authenticate_and_get_sid(pihole_config) + if force: pihole_sessions.pop(name,None) + sid=pihole_sessions.get(name) + if sid: return sid + sid=authenticate_and_get_sid(p) if sid: - with _sessions_lock: - pihole_sessions[name] = sid + with _sessions_lock: pihole_sessions[name]=sid return sid +def pihole_get(p,path,params=None): + sid=get_sid(p) + if not sid: raise RuntimeError('authentication failed') + args={'headers':headers(sid),'params':params,'timeout':DEFAULT_REQUEST_TIMEOUT,'verify':get_verify_setting(p)} + r=requests.get(p['address'].rstrip('/')+path,**args) + if r.status_code==401 and sid!=NO_PASSWORD: + sid=get_sid(p,True) + if not sid: raise RuntimeError('re-authentication failed') + args['headers']=headers(sid); r=requests.get(p['address'].rstrip('/')+path,**args) + r.raise_for_status(); return r -def pihole_get(pihole_config, api_path, params=None): - address = pihole_config['address'].rstrip('/') - sid = get_sid(pihole_config) - if not sid: - raise RuntimeError('authentication failed') - - request_args = { - 'headers': get_headers(sid), - 'params': params, - 'timeout': DEFAULT_REQUEST_TIMEOUT, - 'verify': get_verify_setting(pihole_config), - } - response = requests.get(f"{address}{api_path}", **request_args) - - if response.status_code == 401 and sid != NO_PASSWORD: - sid = get_sid(pihole_config, force=True) - if not sid: - raise RuntimeError('re-authentication failed') - request_args['headers'] = get_headers(sid) - response = requests.get(f"{address}{api_path}", **request_args) - - response.raise_for_status() - return response - - -# -- Frontend Routes -- @bp.route('/') def index(): - icon_url = '' - if manifest_data.get('icons'): - icon_url = manifest_data['icons'][0].get('src', '') - - base_tag = f'' - temp_html = index_template.replace('', f'\n {base_tag}') - return temp_html.replace('{{ICON_URL}}', escape(icon_url)) - - + icon=(manifest_data.get('icons') or [{}])[0].get('src','') + return index_template.replace('',f'\n ').replace('{{ICON_URL}}',escape(icon)) @bp.route('/manifest.json') def serve_manifest(): - manifest_copy = manifest_data.copy() - manifest_copy['start_url'] = html_base - return jsonify(manifest_copy) - - + d=manifest_data.copy(); d['start_url']=html_base; return jsonify(d) @bp.route('/sw.js') -def serve_sw(): - sw_content = sw_template.replace('{{CACHE_URL}}', html_base) - return sw_content, 200, {'Content-Type': 'application/javascript'} - - +def serve_sw(): return sw_template.replace('{{CACHE_URL}}',html_base),200,{'Content-Type':'application/javascript','Cache-Control':'no-cache'} @bp.route('/css/') -def send_css(path): - return send_from_directory(os.path.join(APP_ROOT, 'css'), path) - - +def send_css(path): return send_from_directory(os.path.join(APP_ROOT,'css'),path) @bp.route('/js/') -def send_js(path): - return send_from_directory(os.path.join(APP_ROOT, 'js'), path) - - +def send_js(path): return send_from_directory(os.path.join(APP_ROOT,'js'),path) @bp.route('/favicon.ico') -def favicon(): - return '', 204 - - -# -- API helpers -- -def get_enabled_piholes(): - return [p for p in config.get('piholes', []) if p.get('enabled', True)] - - -def get_filtered_config(): - piholes_filtered = [] - for pihole in get_enabled_piholes(): - item = { - 'name': pihole['name'], - 'enabled': True, - 'link': pihole.get('link', False), - } - if item['link']: - item['address'] = pihole['address'] - piholes_filtered.append(item) - - return { - 'refresh_interval': get_refresh_interval(), - 'queries_refresh_interval': get_queries_refresh_interval(), - 'show_queries': config.get('show_queries', False), - 'show_network_summary': config.get('show_network_summary', True), - 'show_trends': config.get('show_trends', True), - 'piholes': piholes_filtered, - } - +def favicon(): return '',204 + +def enabled(): return [p for p in config.get('piholes',[]) if p.get('enabled',True)] +def filtered_config(): + out=[] + for p in enabled(): + item={'name':p['name'],'enabled':True,'link':p.get('link',False)} + if item['link']: item['address']=p['address'] + out.append(item) + return {'refresh_interval':get_refresh_interval(),'queries_refresh_interval':get_queries_refresh_interval(),'show_queries':config.get('show_queries',False),'show_network_summary':config.get('show_network_summary',True),'show_trends':config.get('show_trends',True),'piholes':out} + +def get_blocking(p): + name=p['name']; now=time.monotonic() + with _cache_lock: + c=_blocking_cache.get(name) + if c and now-c['time']= SLOW_RESPONSE_MS else 'healthy', - } - return name, data - except RuntimeError as exc: - return name, {'error': str(exc), '_pi_dash': {'health': 'auth_error'}} - except requests.exceptions.RequestException as exc: - return name, {'error': str(exc), '_pi_dash': {'health': 'unreachable'}} - except ValueError as exc: - return name, {'error': f'Invalid JSON response: {exc}', '_pi_dash': {'health': 'unreachable'}} - - -def _fetch_all_pihole_data_uncached(): - enabled_piholes = get_enabled_piholes() - if not enabled_piholes: - return {} - - results = {} - with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(enabled_piholes), 10)) as executor: - futures = [executor.submit(fetch_single_pihole, pihole) for pihole in enabled_piholes] - for future in concurrent.futures.as_completed(futures): - name, data = future.result() - results[name] = data + r=pihole_get(p,'/api/stats/summary'); latency=round((time.perf_counter()-start)*1000,1); d=r.json() + d['_pi_dash']={'latency_ms':latency,'blocking':get_blocking(p),'health':'slow' if latency>=SLOW_RESPONSE_MS else 'healthy'} + return name,d + except RuntimeError as e: return name,{'error':str(e),'_pi_dash':{'health':'auth_error'}} + except Exception as e: return name,{'error':str(e),'_pi_dash':{'health':'unreachable'}} + +def _stats_uncached(): + items=enabled(); results={} + if not items: return results + with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(items),10)) as ex: + for f in concurrent.futures.as_completed([ex.submit(fetch_one,p) for p in items]): + n,d=f.result(); results[n]=d return results - -def fetch_all_pihole_data(force=False): - ttl_seconds = get_cache_ttl() / 1000.0 - now = time.monotonic() +def fetch_stats(force=False): + ttl=get_cache_ttl()/1000; now=time.monotonic() with _cache_lock: - if not force and _stats_cache['data'] is not None and now - _stats_cache['time'] < ttl_seconds: - return _stats_cache['data'] - + if not force and _stats_cache['data'] is not None and now-_stats_cache['time'] Date: Tue, 8 Sep 2026 17:23:19 +0530 Subject: [PATCH 03/45] Add split polling, health details, summary, and trends --- js/app.js | 519 ++++++++++++++++++++---------------------------------- 1 file changed, 195 insertions(+), 324 deletions(-) diff --git a/js/app.js b/js/app.js index d13df20..33bd9af 100644 --- a/js/app.js +++ b/js/app.js @@ -1,381 +1,252 @@ document.addEventListener('DOMContentLoaded', () => { - let refreshIntervalId = null; - let stalenessIntervalId = null; + let statsTimer = null; + let queriesTimer = null; + let stalenessTimer = null; let appConfig = null; - let isFetching = false; + let statsFetching = false; + let queriesFetching = false; let lastUpdateTime = null; let queryFeedPaused = false; const lastCursorByPihole = {}; + const trendHistory = {}; + const MAX_TREND_POINTS = 30; async function fetchData(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return await response.json(); + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response.json(); } - function processSummaryData(summaryData) { - if (!summaryData || !summaryData.queries) { - return { - total: 0, - blocked: 0, - percentage: 0, - clients: 0, - rate: 0, - cached: 0, - forwarded: 0, - unique: 0, - domains: 0, - }; - } - - const queries = summaryData.queries; - const clients = summaryData.clients; - const gravity = summaryData.gravity; + function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + function summaryStats(raw) { + if (!raw || !raw.queries) return { total: 0, blocked: 0, percentage: 0, clients: 0, rate: 0, cached: 0, forwarded: 0, unique: 0, domains: 0 }; + const q = raw.queries || {}; + const clients = raw.clients || {}; + const gravity = raw.gravity || {}; return { - total: Number(queries.total) || 0, - blocked: Number(queries.blocked) || 0, - percentage: Number(queries.percent_blocked) || 0, + total: Number(q.total) || 0, + blocked: Number(q.blocked) || 0, + percentage: Number(q.percent_blocked) || 0, clients: Number(clients.active) || 0, - rate: Number(queries.frequency) || 0, - cached: Number(queries.cached) || 0, - forwarded: Number(queries.forwarded) || 0, - unique: Number(queries.unique_domains) || 0, + rate: Number(q.frequency) || 0, + cached: Number(q.cached) || 0, + forwarded: Number(q.forwarded) || 0, + unique: Number(q.unique_domains) || 0, domains: Number(gravity.domains_being_blocked) || 0, }; } - async function updatePiholeUI(piholeName, rawData) { - const section = document.getElementById(`pihole-${piholeName}-section`); - if (!section) return; - - const nameEl = section.querySelector('.pihole-name'); - const rateEl = section.querySelector('.pihole-rate'); - const totalEl = section.querySelector('.pihole-total'); - const blockedEl = section.querySelector('.pihole-blocked'); - const percentEl = section.querySelector('.pihole-percent'); - const clientsEl = section.querySelector('.pihole-clients'); - const statusDotEl = section.querySelector('.status-dot'); - const cacheEl = section.querySelector('.pihole-cache'); - const uniqueEl = section.querySelector('.pihole-unique'); - const domainsEl = section.querySelector('.pihole-domains'); + function healthPresentation(meta = {}, hasError = false) { + if (hasError || meta.health === 'unreachable' || meta.health === 'auth_error') return { dot: 'bg-red-500', text: meta.health === 'auth_error' ? 'Auth failed' : 'Offline', textClass: 'text-red-500' }; + if (meta.blocking === false) return { dot: 'bg-yellow-500', text: 'Blocking OFF', textClass: 'text-yellow-600 dark:text-yellow-500' }; + if (meta.health === 'slow') return { dot: 'bg-yellow-500', text: 'Slow', textClass: 'text-yellow-600 dark:text-yellow-500' }; + return { dot: 'bg-green-500', text: meta.blocking === true ? 'Blocking ON' : 'Online', textClass: 'text-green-600 dark:text-green-500' }; + } - try { - if (rawData.error) throw new Error(rawData.error); + function addTrend(name, value) { + if (!trendHistory[name]) trendHistory[name] = []; + trendHistory[name].push(value); + if (trendHistory[name].length > MAX_TREND_POINTS) trendHistory[name].shift(); + } - const stats = processSummaryData(rawData); + function renderSparkline(name, el) { + if (!el || !appConfig.show_trends) return; + const values = trendHistory[name] || []; + if (values.length < 2) { el.innerHTML = ''; return; } + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${24 - ((v - min) / range) * 20}`).join(' '); + el.innerHTML = ``; + } - let rateValue; - let rateUnit; - if (stats.rate < 1.0) { - rateValue = (stats.rate * 60).toFixed(1); - rateUnit = '/min'; - } else { - rateValue = stats.rate.toFixed(1); - rateUnit = '/sec'; - } - if (rateEl) { - rateEl.textContent = `(${rateValue}${rateUnit})`; - rateEl.classList.remove('text-gray-500', 'dark:text-gray-400'); - rateEl.classList.add('text-teal-500', 'dark:text-teal-400'); - } else { - - nameEl.innerHTML = `${piholeName} (${rateValue}${rateUnit})`; - } + function updatePiholeUI(name, raw) { + const section = document.getElementById(`pihole-${CSS.escape(name)}-section`); + if (!section) return; + const meta = raw?._pi_dash || {}; + const hasError = !!raw?.error; + const status = healthPresentation(meta, hasError); + const dot = section.querySelector('.status-dot'); + const health = section.querySelector('.pihole-health'); + const latency = section.querySelector('.pihole-latency'); + dot.className = `status-dot ${status.dot}`; + health.className = `pihole-health text-xs ${status.textClass}`; + health.textContent = status.text; + latency.textContent = Number.isFinite(Number(meta.latency_ms)) ? `${Number(meta.latency_ms).toFixed(0)} ms` : '-- ms'; + + if (hasError) { + section.querySelectorAll('[data-metric]').forEach(el => el.textContent = el.dataset.metric === 'percent' ? '--%' : '--'); + section.querySelector('.pihole-cache').textContent = '-- / --'; + section.querySelector('.pihole-rate').textContent = '(--/sec)'; + return; + } - totalEl.textContent = stats.total.toLocaleString(); - blockedEl.textContent = stats.blocked.toLocaleString(); - percentEl.textContent = `${stats.percentage.toFixed(1)}%`; - clientsEl.textContent = stats.clients.toLocaleString(); - cacheEl.textContent = `${stats.cached.toLocaleString()} / ${stats.forwarded.toLocaleString()}`; - uniqueEl.textContent = stats.unique.toLocaleString(); - domainsEl.textContent = stats.domains.toLocaleString(); + const s = summaryStats(raw); + let rateValue, rateUnit; + if (s.rate < 1) { rateValue = (s.rate * 60).toFixed(1); rateUnit = '/min'; } + else { rateValue = s.rate.toFixed(1); rateUnit = '/sec'; } + section.querySelector('.pihole-rate').textContent = `(${rateValue}${rateUnit})`; + section.querySelector('.pihole-total').textContent = s.total.toLocaleString(); + section.querySelector('.pihole-blocked').textContent = s.blocked.toLocaleString(); + section.querySelector('.pihole-percent').textContent = `${s.percentage.toFixed(1)}%`; + section.querySelector('.pihole-clients').textContent = s.clients.toLocaleString(); + section.querySelector('.pihole-cache').textContent = `${s.cached.toLocaleString()} / ${s.forwarded.toLocaleString()}`; + section.querySelector('.pihole-unique').textContent = s.unique.toLocaleString(); + section.querySelector('.pihole-domains').textContent = s.domains.toLocaleString(); + addTrend(name, s.rate); + renderSparkline(name, section.querySelector('.pihole-sparkline')); + } - statusDotEl.classList.remove('bg-gray-500', 'bg-red-500'); - statusDotEl.classList.add('bg-green-500'); - } catch (error) { - console.error(`Failed to update Pi-hole ${piholeName} data:`, error); - if (rateEl) { - rateEl.textContent = `(--/sec)`; - rateEl.classList.remove('text-teal-500', 'dark:text-teal-400'); - rateEl.classList.add('text-gray-500', 'dark:text-gray-400'); - } else { - nameEl.innerHTML = `${piholeName} (--/sec)`; - } - [totalEl, blockedEl, clientsEl, uniqueEl, domainsEl].forEach((el) => (el.textContent = '--')); - percentEl.textContent = '--%'; - cacheEl.textContent = '-- / --'; - statusDotEl.classList.remove('bg-gray-500', 'bg-green-500'); - statusDotEl.classList.add('bg-red-500'); - } + function updateNetworkSummary(summary) { + const el = document.getElementById('network-summary'); + if (!el || !appConfig.show_network_summary || !summary) return; + el.classList.remove('hidden'); + el.querySelector('.network-total').textContent = Number(summary.total_queries || 0).toLocaleString(); + el.querySelector('.network-blocked').textContent = Number(summary.blocked_queries || 0).toLocaleString(); + el.querySelector('.network-percent').textContent = `${Number(summary.percent_blocked || 0).toFixed(1)}%`; + el.querySelector('.network-cache').textContent = `${Number(summary.cached_queries || 0).toLocaleString()} / ${Number(summary.forwarded_queries || 0).toLocaleString()}`; + const parts = []; + if (summary.healthy_instances) parts.push(`${summary.healthy_instances} healthy`); + if (summary.slow_instances) parts.push(`${summary.slow_instances} slow`); + if (summary.offline_instances) parts.push(`${summary.offline_instances} offline`); + el.querySelector('.network-health').textContent = parts.join(' ยท ') || 'No active Pi-holes'; } function updateTimestamp() { lastUpdateTime = Date.now(); - const timestampEl = document.getElementById('last-updated'); - const now = new Date(); - timestampEl.textContent = `Last updated: ${now.toLocaleTimeString()}`; - timestampEl.classList.remove('text-yellow-600', 'dark:text-yellow-500'); - timestampEl.classList.add('text-gray-500', 'dark:text-gray-400'); + const el = document.getElementById('last-updated'); + el.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; + el.className = 'text-xs text-gray-500 dark:text-gray-400'; } - function updateStalenessIndicator() { + function updateStaleness() { if (!lastUpdateTime || !appConfig) return; - const timestampEl = document.getElementById('last-updated'); - if (!timestampEl) return; - const elapsed = Date.now() - lastUpdateTime; - const threshold = (appConfig.refresh_interval || 5000) * 2; - if (elapsed > threshold) { - timestampEl.classList.add('text-yellow-600', 'dark:text-yellow-500'); - timestampEl.classList.remove('text-gray-500', 'dark:text-gray-400'); + if (Date.now() - lastUpdateTime > (appConfig.refresh_interval || 5000) * 2) { + document.getElementById('last-updated').className = 'text-xs text-yellow-600 dark:text-yellow-500'; } } - async function refreshDashboard() { - if (isFetching) return; - isFetching = true; + async function refreshStats() { + if (statsFetching) return; + statsFetching = true; try { - - const includeQueries = appConfig && appConfig.show_queries; - const endpoint = includeQueries ? 'data?include_queries=true&length=50' : 'data'; - const data = await fetchData(endpoint); - - - const statsData = data.stats || data; - const queriesData = data.queries; - - - for (const [piholeName, piholeData] of Object.entries(statsData)) { - await updatePiholeUI(piholeName, piholeData); - } + const payload = await fetchData('data?include_summary=true'); + for (const [name, raw] of Object.entries(payload.stats || {})) updatePiholeUI(name, raw); + updateNetworkSummary(payload.summary); updateTimestamp(); - - - if (queriesData) { - renderQueries(queriesData); - } - } catch (error) { - console.error('Failed to refresh dashboard:', error); - } finally { - isFetching = false; - } + } catch (e) { + console.error('Failed to refresh stats:', e); + } finally { statsFetching = false; } + } + + async function refreshQueries() { + if (!appConfig?.show_queries || queriesFetching) return; + queriesFetching = true; + try { renderQueries(await fetchData('queries?length=50')); } + catch (e) { console.error('Failed to refresh queries:', e); } + finally { queriesFetching = false; } } - function startTimer() { - if (refreshIntervalId || !appConfig) return; - const interval = appConfig.refresh_interval || 5000; - refreshIntervalId = setInterval(refreshDashboard, interval); - stalenessIntervalId = setInterval(updateStalenessIndicator, 1000); + function startTimers() { + if (!appConfig) return; + if (!statsTimer) statsTimer = setInterval(refreshStats, appConfig.refresh_interval || 5000); + if (appConfig.show_queries && !queriesTimer) queriesTimer = setInterval(refreshQueries, appConfig.queries_refresh_interval || appConfig.refresh_interval || 5000); + if (!stalenessTimer) stalenessTimer = setInterval(updateStaleness, 1000); } - function stopTimer() { - clearInterval(refreshIntervalId); - refreshIntervalId = null; - clearInterval(stalenessIntervalId); - stalenessIntervalId = null; + function stopTimers() { + clearInterval(statsTimer); clearInterval(queriesTimer); clearInterval(stalenessTimer); + statsTimer = queriesTimer = stalenessTimer = null; } function renderQueries(allQueries) { const container = document.getElementById('background-queries'); - if (!container) return; - const isDark = document.documentElement.classList.contains('dark'); - container.classList.toggle('dark-mode', isDark); - + if (!container || queryFeedPaused) return; const newItems = []; - const pendingCursors = {}; - for (const [piholeName, queries] of Object.entries(allQueries)) { - if (!Array.isArray(queries) || queries.length === 0) continue; - const lastCursor = lastCursorByPihole[piholeName] ?? -Infinity; - let maxCursor = lastCursor; - for (let i = 0; i < queries.length; i++) { - const q = queries[i]; - const id = (q.id !== undefined && q.id !== null) ? Number(q.id) : null; - const timeVal = (q.time !== undefined && q.time !== null) ? Number(q.time) : (q.timestamp ? Number(q.timestamp) : null); - const cursor = (id !== null) ? id : (timeVal !== null ? timeVal : -Infinity); - if (cursor > lastCursor) { - newItems.push({ piholeName, ...q, __cursor: cursor }); - } - if (cursor > maxCursor) maxCursor = cursor; + const pending = {}; + for (const [name, queries] of Object.entries(allQueries || {})) { + if (!Array.isArray(queries)) continue; + const last = lastCursorByPihole[name] ?? -Infinity; + let max = last; + for (const q of queries) { + const id = q.id !== null && q.id !== undefined ? Number(q.id) : null; + const time = q.time !== null && q.time !== undefined ? Number(q.time) : null; + const cursor = id !== null ? id : (time !== null ? time : -Infinity); + if (cursor > last) newItems.push({ ...q, piholeName: name, __cursor: cursor }); + if (cursor > max) max = cursor; } - pendingCursors[piholeName] = maxCursor; + pending[name] = max; } - - if (newItems.length === 0) return; - if (queryFeedPaused) return; - - // Commit cursor advances only now that we know we will render - Object.assign(lastCursorByPihole, pendingCursors); - - newItems.sort((a, b) => a.__cursor - b.__cursor); - - const groups = []; - for (let i = 0; i < newItems.length; i++) { - const item = newItems[i]; - const domain = item.domain || ''; - const piholeName = item.piholeName || ''; - const label = `[${piholeName}] ${domain}`; - const blocked = !!item.blocked; - if (groups.length > 0 && groups[groups.length - 1].label === label) { - const g = groups[groups.length - 1]; - g.count += 1; - g.blocked = g.blocked || blocked; - } else { - groups.push({ label, domain, piholeName, blocked, count: 1 }); - } - } - - const MAX_ROWS = 100; - for (let index = 0; index < groups.length; index++) { - const group = groups[index]; - const lastLi = container.lastElementChild; - if (lastLi && lastLi.dataset && lastLi.dataset.label === group.label) { - const prev = Number(lastLi.dataset.count || '1'); - const nextCount = prev + group.count; - lastLi.dataset.count = String(nextCount); - lastLi.textContent = `${group.label} (x${nextCount})`; - if (group.blocked) { - lastLi.classList.remove('text-green-600', 'dark:text-green-500'); - lastLi.classList.add('text-red-600', 'dark:text-red-500'); - lastLi.style.filter = 'drop-shadow(0 0 2px rgba(220, 38, 38, 0.3))'; - } - } else { - const li = document.createElement('li'); - li.className = 'opacity-0 translate-y-1 text-[10px] leading-tight px-1 whitespace-nowrap'; - li.style.textOverflow = 'ellipsis'; - li.dataset.label = group.label; - li.dataset.count = String(group.count); - li.textContent = group.count > 1 ? `${group.label} (x${group.count})` : group.label; - if (group.blocked) { - li.classList.add('text-red-600', 'dark:text-red-500'); - li.style.filter = 'drop-shadow(0 0 2px rgba(220, 38, 38, 0.3))'; - } else { - li.classList.add('text-green-600', 'dark:text-green-500'); - li.style.filter = 'drop-shadow(0 0 2px rgba(22, 163, 74, 0.25))'; - } - const delay = Math.min(index * 15, 300); - setTimeout(() => { - requestAnimationFrame(() => { - li.style.transition = 'opacity .3s ease, transform .3s ease'; - li.style.opacity = '0.9'; - li.style.transform = 'translateY(0)'; - }); - }, delay); - container.appendChild(li); - } + if (!newItems.length) return; + Object.assign(lastCursorByPihole, pending); + newItems.sort((a,b) => a.__cursor - b.__cursor); + for (const item of newItems) { + const li = document.createElement('li'); + li.className = `query-row ${item.blocked ? 'text-red-600 dark:text-red-500' : 'text-green-600 dark:text-green-500'}`; + li.textContent = `[${item.piholeName}] ${item.domain || ''}`; + container.appendChild(li); } + while (container.children.length > 100) container.removeChild(container.firstChild); + } - while (container.children.length > MAX_ROWS) { - container.removeChild(container.firstChild); - } + function createCard(p) { + const section = document.createElement('section'); + section.id = `pihole-${p.name}-section`; + section.className = 'bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full'; + const safeName = escapeHtml(p.name); + const name = p.link ? `${safeName}` : safeName; + section.innerHTML = ` +
+

${name} (--/sec)

Checking-- ms
+
+
+
+
Total Queries--
+
Queries Blocked--
+
Percent Blocked--%
+
Cached / Forwarded-- / --
+
Unique Domains--
+
Active Clients--
+
Domains on Lists--
+
`; + return section; } async function init() { try { - - const initData = await fetchData('init'); - appConfig = initData.config; - - const mainContent = document.querySelector('main'); - mainContent.innerHTML = ''; - - const enabledPiholes = appConfig.piholes.filter((p) => p.enabled); - - - enabledPiholes.forEach((pihole) => { - const section = document.createElement('section'); - section.id = `pihole-${pihole.name}-section`; - section.className = 'bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full'; - - const nameContent = pihole.link - ? `${pihole.name}` - : `${pihole.name}`; - section.innerHTML = ` -
-

${nameContent} (--/sec)

- -
-
-
- Total Queries - -- -
-
- Queries Blocked - -- -
-
- Percent Blocked - --% -
-
- Cachd / Fwded - -- / -- -
-
- Unique Domains - -- -
-
- Active Clients - -- -
-
- Domains on Lists - -- -
-
- `; - mainContent.appendChild(section); - }); - - - for (const [piholeName, piholeData] of Object.entries(initData.data)) { - await updatePiholeUI(piholeName, piholeData); - } + const data = await fetchData('init'); + appConfig = data.config; + const main = document.querySelector('main'); + main.innerHTML = ''; + for (const p of appConfig.piholes || []) main.appendChild(createCard(p)); + for (const [name, raw] of Object.entries(data.data || {})) updatePiholeUI(name, raw); + updateNetworkSummary(data.summary); + if (data.queries) renderQueries(data.queries); updateTimestamp(); - - if (initData.queries) { - renderQueries(initData.queries); - } - - startTimer(); - } catch (error) { - console.error('Failed to initialize dashboard:', error); + startTimers(); + } catch (e) { + console.error('Failed to initialize dashboard:', e); + document.getElementById('last-updated').textContent = 'Unable to initialize dashboard'; } } document.addEventListener('visibilitychange', () => { - if (document.hidden) { - stopTimer(); - } else { - refreshDashboard(); - startTimer(); - } + if (document.hidden) stopTimers(); + else { refreshStats(); refreshQueries(); startTimers(); } }); - - window.addEventListener('offline', () => stopTimer()); - window.addEventListener('online', () => { refreshDashboard(); startTimer(); }); - - const queriesContainer = document.getElementById('background-queries'); - if (queriesContainer) { - queriesContainer.addEventListener('mouseenter', () => { queryFeedPaused = true; }); - queriesContainer.addEventListener('mouseleave', () => { queryFeedPaused = false; }); - } - - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('sw.js').then( - (registration) => { - console.log('ServiceWorker registration successful with scope: ', registration.scope); - }, - (err) => { - console.log('ServiceWorker registration failed: ', err); - } - ); - }); - } - + window.addEventListener('offline', stopTimers); + window.addEventListener('online', () => { refreshStats(); refreshQueries(); startTimers(); }); + const queries = document.getElementById('background-queries'); + queries?.addEventListener('mouseenter', () => { queryFeedPaused = true; }); + queries?.addEventListener('mouseleave', () => { queryFeedPaused = false; }); + if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(console.error)); init(); -}); \ No newline at end of file +}); From e037746a0dba4d3838587e43acb49b12d4d7cdf6 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:24:08 +0530 Subject: [PATCH 04/45] Add network summary shell and remove external font dependency --- index.html | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index df552c5..17442fa 100644 --- a/index.html +++ b/index.html @@ -4,22 +4,12 @@ Pi-hole Dashboard - - - - - - - - -
-
+ +
+

Pi-Dash

Last updated: Never

+ +
- - - + - \ No newline at end of file + From 8ded08b6af36e4d4b2d0b0d390c63a841a9b9c3c Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:25:39 +0530 Subject: [PATCH 05/45] Fix card lookup and keep new UI independent of Tailwind rebuild --- js/app.js | 287 +++++++++--------------------------------------------- 1 file changed, 47 insertions(+), 240 deletions(-) diff --git a/js/app.js b/js/app.js index 33bd9af..f91bb96 100644 --- a/js/app.js +++ b/js/app.js @@ -1,252 +1,59 @@ document.addEventListener('DOMContentLoaded', () => { - let statsTimer = null; - let queriesTimer = null; - let stalenessTimer = null; - let appConfig = null; - let statsFetching = false; - let queriesFetching = false; - let lastUpdateTime = null; - let queryFeedPaused = false; - const lastCursorByPihole = {}; - const trendHistory = {}; - const MAX_TREND_POINTS = 30; + let statsTimer = null, queriesTimer = null, stalenessTimer = null; + let appConfig = null, statsFetching = false, queriesFetching = false, lastUpdateTime = null, queryFeedPaused = false; + const lastCursorByPihole = {}, trendHistory = {}, MAX_TREND_POINTS = 30; async function fetchData(url) { const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response.json(); } - - function escapeHtml(value) { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - + function escapeHtml(value) { return String(value).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",'''); } function summaryStats(raw) { - if (!raw || !raw.queries) return { total: 0, blocked: 0, percentage: 0, clients: 0, rate: 0, cached: 0, forwarded: 0, unique: 0, domains: 0 }; - const q = raw.queries || {}; - const clients = raw.clients || {}; - const gravity = raw.gravity || {}; - return { - total: Number(q.total) || 0, - blocked: Number(q.blocked) || 0, - percentage: Number(q.percent_blocked) || 0, - clients: Number(clients.active) || 0, - rate: Number(q.frequency) || 0, - cached: Number(q.cached) || 0, - forwarded: Number(q.forwarded) || 0, - unique: Number(q.unique_domains) || 0, - domains: Number(gravity.domains_being_blocked) || 0, - }; - } - - function healthPresentation(meta = {}, hasError = false) { - if (hasError || meta.health === 'unreachable' || meta.health === 'auth_error') return { dot: 'bg-red-500', text: meta.health === 'auth_error' ? 'Auth failed' : 'Offline', textClass: 'text-red-500' }; - if (meta.blocking === false) return { dot: 'bg-yellow-500', text: 'Blocking OFF', textClass: 'text-yellow-600 dark:text-yellow-500' }; - if (meta.health === 'slow') return { dot: 'bg-yellow-500', text: 'Slow', textClass: 'text-yellow-600 dark:text-yellow-500' }; - return { dot: 'bg-green-500', text: meta.blocking === true ? 'Blocking ON' : 'Online', textClass: 'text-green-600 dark:text-green-500' }; - } - - function addTrend(name, value) { - if (!trendHistory[name]) trendHistory[name] = []; - trendHistory[name].push(value); - if (trendHistory[name].length > MAX_TREND_POINTS) trendHistory[name].shift(); - } - - function renderSparkline(name, el) { + if (!raw || !raw.queries) return { total:0,blocked:0,percentage:0,clients:0,rate:0,cached:0,forwarded:0,unique:0,domains:0 }; + const q=raw.queries||{}, clients=raw.clients||{}, gravity=raw.gravity||{}; + return { total:Number(q.total)||0, blocked:Number(q.blocked)||0, percentage:Number(q.percent_blocked)||0, clients:Number(clients.active)||0, rate:Number(q.frequency)||0, cached:Number(q.cached)||0, forwarded:Number(q.forwarded)||0, unique:Number(q.unique_domains)||0, domains:Number(gravity.domains_being_blocked)||0 }; + } + function healthPresentation(meta={}, hasError=false) { + if (hasError || meta.health==='unreachable' || meta.health==='auth_error') return {dot:'bg-red-500',text:meta.health==='auth_error'?'Auth failed':'Offline',textClass:'text-red-500'}; + if (meta.blocking===false) return {dot:'bg-yellow-500',text:'Blocking OFF',textClass:'text-yellow-600 dark:text-yellow-500'}; + if (meta.health==='slow') return {dot:'bg-yellow-500',text:'Slow',textClass:'text-yellow-600 dark:text-yellow-500'}; + return {dot:'bg-green-500',text:meta.blocking===true?'Blocking ON':'Online',textClass:'text-green-600 dark:text-green-500'}; + } + function addTrend(name,value) { (trendHistory[name] ||= []).push(value); if (trendHistory[name].length>MAX_TREND_POINTS) trendHistory[name].shift(); } + function renderSparkline(name,el) { if (!el || !appConfig.show_trends) return; - const values = trendHistory[name] || []; - if (values.length < 2) { el.innerHTML = ''; return; } - const min = Math.min(...values); - const max = Math.max(...values); - const range = max - min || 1; - const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${24 - ((v - min) / range) * 20}`).join(' '); - el.innerHTML = ``; + const values=trendHistory[name]||[]; if(values.length<2){el.innerHTML='';return;} + const min=Math.min(...values), max=Math.max(...values), range=max-min||1; + const points=values.map((v,i)=>`${(i/(values.length-1))*100},${24-((v-min)/range)*20}`).join(' '); + el.innerHTML=``; + } + function updatePiholeUI(name,raw) { + const section=document.getElementById(`pihole-${name}-section`); if(!section)return; + const meta=raw?._pi_dash||{}, hasError=!!raw?.error, status=healthPresentation(meta,hasError); + const dot=section.querySelector('.status-dot'), health=section.querySelector('.pihole-health'), latency=section.querySelector('.pihole-latency'); + dot.className=`status-dot ${status.dot}`; health.className=`pihole-health text-xs ${status.textClass}`; health.textContent=status.text; + latency.textContent=Number.isFinite(Number(meta.latency_ms))?`${Number(meta.latency_ms).toFixed(0)} ms`:'-- ms'; + if(hasError){ section.querySelectorAll('[data-metric]').forEach(el=>el.textContent=el.dataset.metric==='percent'?'--%':'--'); section.querySelector('.pihole-cache').textContent='-- / --'; section.querySelector('.pihole-rate').textContent='(--/sec)'; return; } + const s=summaryStats(raw); let rateValue,rateUnit; + if(s.rate<1){rateValue=(s.rate*60).toFixed(1);rateUnit='/min';}else{rateValue=s.rate.toFixed(1);rateUnit='/sec';} + section.querySelector('.pihole-rate').textContent=`(${rateValue}${rateUnit})`; + section.querySelector('.pihole-total').textContent=s.total.toLocaleString(); section.querySelector('.pihole-blocked').textContent=s.blocked.toLocaleString(); section.querySelector('.pihole-percent').textContent=`${s.percentage.toFixed(1)}%`; section.querySelector('.pihole-clients').textContent=s.clients.toLocaleString(); section.querySelector('.pihole-cache').textContent=`${s.cached.toLocaleString()} / ${s.forwarded.toLocaleString()}`; section.querySelector('.pihole-unique').textContent=s.unique.toLocaleString(); section.querySelector('.pihole-domains').textContent=s.domains.toLocaleString(); + addTrend(name,s.rate); renderSparkline(name,section.querySelector('.pihole-sparkline')); } - - function updatePiholeUI(name, raw) { - const section = document.getElementById(`pihole-${CSS.escape(name)}-section`); - if (!section) return; - const meta = raw?._pi_dash || {}; - const hasError = !!raw?.error; - const status = healthPresentation(meta, hasError); - const dot = section.querySelector('.status-dot'); - const health = section.querySelector('.pihole-health'); - const latency = section.querySelector('.pihole-latency'); - dot.className = `status-dot ${status.dot}`; - health.className = `pihole-health text-xs ${status.textClass}`; - health.textContent = status.text; - latency.textContent = Number.isFinite(Number(meta.latency_ms)) ? `${Number(meta.latency_ms).toFixed(0)} ms` : '-- ms'; - - if (hasError) { - section.querySelectorAll('[data-metric]').forEach(el => el.textContent = el.dataset.metric === 'percent' ? '--%' : '--'); - section.querySelector('.pihole-cache').textContent = '-- / --'; - section.querySelector('.pihole-rate').textContent = '(--/sec)'; - return; - } - - const s = summaryStats(raw); - let rateValue, rateUnit; - if (s.rate < 1) { rateValue = (s.rate * 60).toFixed(1); rateUnit = '/min'; } - else { rateValue = s.rate.toFixed(1); rateUnit = '/sec'; } - section.querySelector('.pihole-rate').textContent = `(${rateValue}${rateUnit})`; - section.querySelector('.pihole-total').textContent = s.total.toLocaleString(); - section.querySelector('.pihole-blocked').textContent = s.blocked.toLocaleString(); - section.querySelector('.pihole-percent').textContent = `${s.percentage.toFixed(1)}%`; - section.querySelector('.pihole-clients').textContent = s.clients.toLocaleString(); - section.querySelector('.pihole-cache').textContent = `${s.cached.toLocaleString()} / ${s.forwarded.toLocaleString()}`; - section.querySelector('.pihole-unique').textContent = s.unique.toLocaleString(); - section.querySelector('.pihole-domains').textContent = s.domains.toLocaleString(); - addTrend(name, s.rate); - renderSparkline(name, section.querySelector('.pihole-sparkline')); - } - function updateNetworkSummary(summary) { - const el = document.getElementById('network-summary'); - if (!el || !appConfig.show_network_summary || !summary) return; - el.classList.remove('hidden'); - el.querySelector('.network-total').textContent = Number(summary.total_queries || 0).toLocaleString(); - el.querySelector('.network-blocked').textContent = Number(summary.blocked_queries || 0).toLocaleString(); - el.querySelector('.network-percent').textContent = `${Number(summary.percent_blocked || 0).toFixed(1)}%`; - el.querySelector('.network-cache').textContent = `${Number(summary.cached_queries || 0).toLocaleString()} / ${Number(summary.forwarded_queries || 0).toLocaleString()}`; - const parts = []; - if (summary.healthy_instances) parts.push(`${summary.healthy_instances} healthy`); - if (summary.slow_instances) parts.push(`${summary.slow_instances} slow`); - if (summary.offline_instances) parts.push(`${summary.offline_instances} offline`); - el.querySelector('.network-health').textContent = parts.join(' ยท ') || 'No active Pi-holes'; - } - - function updateTimestamp() { - lastUpdateTime = Date.now(); - const el = document.getElementById('last-updated'); - el.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; - el.className = 'text-xs text-gray-500 dark:text-gray-400'; - } - - function updateStaleness() { - if (!lastUpdateTime || !appConfig) return; - if (Date.now() - lastUpdateTime > (appConfig.refresh_interval || 5000) * 2) { - document.getElementById('last-updated').className = 'text-xs text-yellow-600 dark:text-yellow-500'; - } - } - - async function refreshStats() { - if (statsFetching) return; - statsFetching = true; - try { - const payload = await fetchData('data?include_summary=true'); - for (const [name, raw] of Object.entries(payload.stats || {})) updatePiholeUI(name, raw); - updateNetworkSummary(payload.summary); - updateTimestamp(); - } catch (e) { - console.error('Failed to refresh stats:', e); - } finally { statsFetching = false; } - } - - async function refreshQueries() { - if (!appConfig?.show_queries || queriesFetching) return; - queriesFetching = true; - try { renderQueries(await fetchData('queries?length=50')); } - catch (e) { console.error('Failed to refresh queries:', e); } - finally { queriesFetching = false; } - } - - function startTimers() { - if (!appConfig) return; - if (!statsTimer) statsTimer = setInterval(refreshStats, appConfig.refresh_interval || 5000); - if (appConfig.show_queries && !queriesTimer) queriesTimer = setInterval(refreshQueries, appConfig.queries_refresh_interval || appConfig.refresh_interval || 5000); - if (!stalenessTimer) stalenessTimer = setInterval(updateStaleness, 1000); - } - - function stopTimers() { - clearInterval(statsTimer); clearInterval(queriesTimer); clearInterval(stalenessTimer); - statsTimer = queriesTimer = stalenessTimer = null; - } - - function renderQueries(allQueries) { - const container = document.getElementById('background-queries'); - if (!container || queryFeedPaused) return; - const newItems = []; - const pending = {}; - for (const [name, queries] of Object.entries(allQueries || {})) { - if (!Array.isArray(queries)) continue; - const last = lastCursorByPihole[name] ?? -Infinity; - let max = last; - for (const q of queries) { - const id = q.id !== null && q.id !== undefined ? Number(q.id) : null; - const time = q.time !== null && q.time !== undefined ? Number(q.time) : null; - const cursor = id !== null ? id : (time !== null ? time : -Infinity); - if (cursor > last) newItems.push({ ...q, piholeName: name, __cursor: cursor }); - if (cursor > max) max = cursor; - } - pending[name] = max; - } - if (!newItems.length) return; - Object.assign(lastCursorByPihole, pending); - newItems.sort((a,b) => a.__cursor - b.__cursor); - for (const item of newItems) { - const li = document.createElement('li'); - li.className = `query-row ${item.blocked ? 'text-red-600 dark:text-red-500' : 'text-green-600 dark:text-green-500'}`; - li.textContent = `[${item.piholeName}] ${item.domain || ''}`; - container.appendChild(li); - } - while (container.children.length > 100) container.removeChild(container.firstChild); - } - - function createCard(p) { - const section = document.createElement('section'); - section.id = `pihole-${p.name}-section`; - section.className = 'bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full'; - const safeName = escapeHtml(p.name); - const name = p.link ? `${safeName}` : safeName; - section.innerHTML = ` -
-

${name} (--/sec)

Checking-- ms
-
-
-
-
Total Queries--
-
Queries Blocked--
-
Percent Blocked--%
-
Cached / Forwarded-- / --
-
Unique Domains--
-
Active Clients--
-
Domains on Lists--
-
`; - return section; - } - - async function init() { - try { - const data = await fetchData('init'); - appConfig = data.config; - const main = document.querySelector('main'); - main.innerHTML = ''; - for (const p of appConfig.piholes || []) main.appendChild(createCard(p)); - for (const [name, raw] of Object.entries(data.data || {})) updatePiholeUI(name, raw); - updateNetworkSummary(data.summary); - if (data.queries) renderQueries(data.queries); - updateTimestamp(); - startTimers(); - } catch (e) { - console.error('Failed to initialize dashboard:', e); - document.getElementById('last-updated').textContent = 'Unable to initialize dashboard'; - } - } - - document.addEventListener('visibilitychange', () => { - if (document.hidden) stopTimers(); - else { refreshStats(); refreshQueries(); startTimers(); } - }); - window.addEventListener('offline', stopTimers); - window.addEventListener('online', () => { refreshStats(); refreshQueries(); startTimers(); }); - const queries = document.getElementById('background-queries'); - queries?.addEventListener('mouseenter', () => { queryFeedPaused = true; }); - queries?.addEventListener('mouseleave', () => { queryFeedPaused = false; }); - if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(console.error)); - init(); + const el=document.getElementById('network-summary'); if(!el || !appConfig.show_network_summary || !summary)return; + el.classList.remove('hidden'); el.querySelector('.network-total').textContent=Number(summary.total_queries||0).toLocaleString(); el.querySelector('.network-blocked').textContent=Number(summary.blocked_queries||0).toLocaleString(); el.querySelector('.network-percent').textContent=`${Number(summary.percent_blocked||0).toFixed(1)}%`; el.querySelector('.network-cache').textContent=`${Number(summary.cached_queries||0).toLocaleString()} / ${Number(summary.forwarded_queries||0).toLocaleString()}`; + const parts=[]; if(summary.healthy_instances)parts.push(`${summary.healthy_instances} healthy`); if(summary.slow_instances)parts.push(`${summary.slow_instances} slow`); if(summary.offline_instances)parts.push(`${summary.offline_instances} offline`); el.querySelector('.network-health').textContent=parts.join(' ยท ')||'No active Pi-holes'; + } + function updateTimestamp(){ lastUpdateTime=Date.now(); const el=document.getElementById('last-updated'); el.textContent=`Last updated: ${new Date().toLocaleTimeString()}`; el.className='text-xs text-gray-500 dark:text-gray-400'; } + function updateStaleness(){ if(lastUpdateTime&&appConfig&&Date.now()-lastUpdateTime>(appConfig.refresh_interval||5000)*2) document.getElementById('last-updated').className='text-xs text-yellow-600 dark:text-yellow-500'; } + async function refreshStats(){ if(statsFetching)return; statsFetching=true; try{const payload=await fetchData('data?include_summary=true'); for(const [name,raw] of Object.entries(payload.stats||{}))updatePiholeUI(name,raw); updateNetworkSummary(payload.summary); updateTimestamp();}catch(e){console.error('Failed to refresh stats:',e);}finally{statsFetching=false;} } + async function refreshQueries(){ if(!appConfig?.show_queries||queriesFetching)return; queriesFetching=true; try{renderQueries(await fetchData('queries?length=50'));}catch(e){console.error('Failed to refresh queries:',e);}finally{queriesFetching=false;} } + function startTimers(){ if(!appConfig)return; if(!statsTimer)statsTimer=setInterval(refreshStats,appConfig.refresh_interval||5000); if(appConfig.show_queries&&!queriesTimer)queriesTimer=setInterval(refreshQueries,appConfig.queries_refresh_interval||appConfig.refresh_interval||5000); if(!stalenessTimer)stalenessTimer=setInterval(updateStaleness,1000); } + function stopTimers(){ clearInterval(statsTimer);clearInterval(queriesTimer);clearInterval(stalenessTimer);statsTimer=queriesTimer=stalenessTimer=null; } + function renderQueries(allQueries){ const container=document.getElementById('background-queries'); if(!container||queryFeedPaused)return; const newItems=[],pending={}; for(const[name,queries]of Object.entries(allQueries||{})){if(!Array.isArray(queries))continue;const last=lastCursorByPihole[name]??-Infinity;let max=last;for(const q of queries){const id=q.id!=null?Number(q.id):null,time=q.time!=null?Number(q.time):null,cursor=id!==null?id:(time!==null?time:-Infinity);if(cursor>last)newItems.push({...q,piholeName:name,__cursor:cursor});if(cursor>max)max=cursor;}pending[name]=max;} if(!newItems.length)return;Object.assign(lastCursorByPihole,pending);newItems.sort((a,b)=>a.__cursor-b.__cursor);for(const item of newItems){const li=document.createElement('li');li.className=`opacity-90 text-[10px] leading-tight px-1 whitespace-nowrap ${item.blocked?'text-red-600 dark:text-red-500':'text-green-600 dark:text-green-500'}`;li.textContent=`[${item.piholeName}] ${item.domain||''}`;container.appendChild(li);}while(container.children.length>100)container.removeChild(container.firstChild); } + function createCard(p){ const section=document.createElement('section');section.id=`pihole-${p.name}-section`;section.className='bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full';const safeName=escapeHtml(p.name),name=p.link?`${safeName}`:safeName; section.innerHTML=`

${name} (--/sec)

Checking -- ms
Total Queries--
Queries Blocked--
Percent Blocked--%
Cached / Forwarded-- / --
Unique Domains--
Active Clients--
Domains on Lists--
`; return section; } + async function init(){ try{const data=await fetchData('init');appConfig=data.config;const main=document.querySelector('main');main.innerHTML='';for(const p of appConfig.piholes||[])main.appendChild(createCard(p));for(const[name,raw]of Object.entries(data.data||{}))updatePiholeUI(name,raw);updateNetworkSummary(data.summary);if(data.queries)renderQueries(data.queries);updateTimestamp();startTimers();}catch(e){console.error('Failed to initialize dashboard:',e);document.getElementById('last-updated').textContent='Unable to initialize dashboard';} } + document.addEventListener('visibilitychange',()=>{if(document.hidden)stopTimers();else{refreshStats();refreshQueries();startTimers();}});window.addEventListener('offline',stopTimers);window.addEventListener('online',()=>{refreshStats();refreshQueries();startTimers();});const queries=document.getElementById('background-queries');queries?.addEventListener('mouseenter',()=>{queryFeedPaused=true;});queries?.addEventListener('mouseleave',()=>{queryFeedPaused=false;});if('serviceWorker'in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('sw.js').catch(console.error));init(); }); From 91a91de3bb1c3e1f0ad6bc7a9a3461a897e353d0 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:26:10 +0530 Subject: [PATCH 06/45] Keep summary layout compatible with existing compiled CSS --- index.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index 17442fa..fa5764f 100644 --- a/index.html +++ b/index.html @@ -18,23 +18,23 @@ } - -
-
+ +
+

Pi-Dash

Last updated: Never

-
- -
From 326baf67b3743cc9c62785cf1eebf1c070b3c768 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:27:53 +0530 Subject: [PATCH 11/45] Show network summary without generated hidden utility --- js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index f91bb96..522b2cb 100644 --- a/js/app.js +++ b/js/app.js @@ -43,7 +43,7 @@ document.addEventListener('DOMContentLoaded', () => { } function updateNetworkSummary(summary) { const el=document.getElementById('network-summary'); if(!el || !appConfig.show_network_summary || !summary)return; - el.classList.remove('hidden'); el.querySelector('.network-total').textContent=Number(summary.total_queries||0).toLocaleString(); el.querySelector('.network-blocked').textContent=Number(summary.blocked_queries||0).toLocaleString(); el.querySelector('.network-percent').textContent=`${Number(summary.percent_blocked||0).toFixed(1)}%`; el.querySelector('.network-cache').textContent=`${Number(summary.cached_queries||0).toLocaleString()} / ${Number(summary.forwarded_queries||0).toLocaleString()}`; + el.style.display='block'; el.querySelector('.network-total').textContent=Number(summary.total_queries||0).toLocaleString(); el.querySelector('.network-blocked').textContent=Number(summary.blocked_queries||0).toLocaleString(); el.querySelector('.network-percent').textContent=`${Number(summary.percent_blocked||0).toFixed(1)}%`; el.querySelector('.network-cache').textContent=`${Number(summary.cached_queries||0).toLocaleString()} / ${Number(summary.forwarded_queries||0).toLocaleString()}`; const parts=[]; if(summary.healthy_instances)parts.push(`${summary.healthy_instances} healthy`); if(summary.slow_instances)parts.push(`${summary.slow_instances} slow`); if(summary.offline_instances)parts.push(`${summary.offline_instances} offline`); el.querySelector('.network-health').textContent=parts.join(' ยท ')||'No active Pi-holes'; } function updateTimestamp(){ lastUpdateTime=Date.now(); const el=document.getElementById('last-updated'); el.textContent=`Last updated: ${new Date().toLocaleTimeString()}`; el.className='text-xs text-gray-500 dark:text-gray-400'; } From 811743f354d660087a934c101634f021f17859e5 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:28:11 +0530 Subject: [PATCH 12/45] Run Pi-Dash with Gunicorn and add container healthcheck --- Dockerfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 46e7d78..7f62f4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,18 @@ FROM python:3-alpine + WORKDIR /app COPY requirements.txt . -RUN pip install -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN addgroup -S pidash && adduser -S pidash -G pidash && chown -R pidash:pidash /app +USER pidash + EXPOSE 5001 -CMD [ "python", "proxy.py" ] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5001/health', timeout=3)" || exit 1 + +CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "1", "--threads", "4", "--timeout", "30", "proxy:app"] From f75fdfc02ca3aaec16823d146abfcb5f04b948d0 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:28:28 +0530 Subject: [PATCH 13/45] Add regression tests for compatibility and summary behavior --- tests/test_proxy.py | 97 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/test_proxy.py diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..60c44e0 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,97 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault('PIHOLE_PRIMARY_PASSWORD', 'test-secret') + +import proxy + + +class ConfigCompatibilityTests(unittest.TestCase): + def setUp(self): + self.original = proxy.config + + def tearDown(self): + proxy.config = self.original + + def test_query_interval_inherits_existing_refresh_interval(self): + proxy.config = {'refresh_interval': 1234, 'piholes': []} + self.assertEqual(proxy.get_refresh_interval(), 1234) + self.assertEqual(proxy.get_queries_refresh_interval(), 1234) + + def test_code_defaults_work_without_optional_flags(self): + proxy.config = {'piholes': []} + self.assertEqual(proxy.get_refresh_interval(), proxy.DEFAULT_REFRESH_INTERVAL) + self.assertEqual(proxy.get_queries_refresh_interval(), proxy.DEFAULT_REFRESH_INTERVAL) + self.assertFalse(proxy.filtered_config()['show_queries']) + + def test_new_query_interval_overrides_old_refresh_interval(self): + proxy.config = {'refresh_interval': 2000, 'queries_refresh_interval': 3000, 'piholes': []} + self.assertEqual(proxy.get_queries_refresh_interval(), 3000) + + def test_default_cache_never_exceeds_half_fastest_poll(self): + proxy.config = {'refresh_interval': 1000, 'queries_refresh_interval': 3000, 'piholes': []} + self.assertLessEqual(proxy.get_cache_ttl(), 500) + + def test_zero_enabled_piholes_is_valid(self): + proxy.config = {'piholes': [{'name': 'Off', 'enabled': False}]} + self.assertEqual(proxy._stats_uncached(), {}) + self.assertEqual(proxy._queries_uncached(50), {}) + + def test_environment_secret_resolution(self): + os.environ['PI_DASH_TEST_SECRET'] = 'resolved' + self.assertEqual(proxy.resolve_secret('${PI_DASH_TEST_SECRET}'), 'resolved') + self.assertEqual(proxy.resolve_secret('plain'), 'plain') + + +class SummaryTests(unittest.TestCase): + def test_network_summary_only_aggregates_safe_metrics(self): + data = { + 'one': { + 'queries': {'total': 100, 'blocked': 10, 'cached': 60, 'forwarded': 40}, + '_pi_dash': {'health': 'healthy'}, + }, + 'two': { + 'queries': {'total': 50, 'blocked': 15, 'cached': 20, 'forwarded': 30}, + '_pi_dash': {'health': 'slow'}, + }, + 'three': {'error': 'offline', '_pi_dash': {'health': 'unreachable'}}, + } + result = proxy.network_summary(data) + self.assertEqual(result['total_queries'], 150) + self.assertEqual(result['blocked_queries'], 25) + self.assertEqual(result['percent_blocked'], 16.7) + self.assertEqual(result['cached_queries'], 80) + self.assertEqual(result['forwarded_queries'], 70) + self.assertEqual(result['healthy_instances'], 1) + self.assertEqual(result['slow_instances'], 1) + self.assertEqual(result['offline_instances'], 1) + self.assertNotIn('clients', result) + self.assertNotIn('unique_domains', result) + + +class RouteCompatibilityTests(unittest.TestCase): + def test_legacy_data_shape_is_preserved(self): + sample = {'Primary': {'queries': {'total': 1}}} + with proxy.app.test_client() as client, patch.object(proxy, 'fetch_stats', return_value=sample): + response = client.get('/data') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json(), sample) + + def test_rich_data_shape_is_opt_in(self): + sample = {'Primary': {'queries': {'total': 10, 'blocked': 2, 'cached': 5, 'forwarded': 5}, '_pi_dash': {'health': 'healthy'}}} + with proxy.app.test_client() as client, patch.object(proxy, 'fetch_stats', return_value=sample): + response = client.get('/data?include_summary=true') + payload = response.get_json() + self.assertIn('stats', payload) + self.assertIn('summary', payload) + + def test_health_endpoint_does_not_require_pihole_access(self): + with proxy.app.test_client() as client: + response = client.get('/health') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()['status'], 'ok') + + +if __name__ == '__main__': + unittest.main() From 7fa82ee431292897c015eabd36fef7590d6f2a0c Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:28:52 +0530 Subject: [PATCH 14/45] Add branch CI for backend regression tests --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e1213a3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: + - '**' + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + - name: Install dependencies + run: pip install -r requirements.txt + - name: Prepare test configuration + run: cp config-example.json config.json + - name: Run regression tests + run: python -m unittest discover -s tests -v + - name: Compile-check backend + run: python -m py_compile proxy.py From c81bc9cab28d08108b9fe4368c04dd4824591542 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:29:45 +0530 Subject: [PATCH 15/45] Document dashboard health, polling, security, and compatibility options --- README.md | 249 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 145 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 6ac7aea..10ba174 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,189 @@ [![CI/CD](https://github.com/surajverma/pi-dash/actions/workflows/main.yml/badge.svg)](https://github.com/surajverma/pi-dash/actions/workflows/main.yml) +[![Tests](https://github.com/surajverma/pi-dash/actions/workflows/test.yml/badge.svg)](https://github.com/surajverma/pi-dash/actions/workflows/test.yml) ![Latest Release](https://img.shields.io/github/v/release/surajverma/pi-dash?include_prereleases) [![GitHub last commit](https://img.shields.io/github/last-commit/surajverma/pi-dash)](https://github.com/surajverma/pi-dash/commits/main) [![GitHub issues](https://img.shields.io/github/issues/surajverma/pi-dash)](https://github.com/surajverma/pi-dash/issues) [![GitHub Stars](https://img.shields.io/github/stars/surajverma/pi-dash?style=social)](https://github.com/surajverma/pi-dash/stargazers) -[![Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Fsurajverma%2Fpi-dash&query=downloadCount&style=social&logo=github&label=Docker%20Pulls)](https://github.com/surajverma/pi-dash/pkgs/container/pi-dash) # Pi-Dash: A Minimalist Pi-hole Dashboard -Pi-Dash is a simple, lightweight dashboard for monitoring multiple Pi-hole instances. It provides a clean, at-a-glance, responsive view of your Pi-hole statistics. +Pi-Dash is a lightweight dashboard for monitoring one or more Pi-hole instances. It intentionally stays focused on at-a-glance health and DNS activity instead of replacing the Pi-hole administration interface. ## Features -- **Multiple Pi-hole Support:** Monitor all your Pi-hole instances from a single dashboard. You can add as many as Pi-hole's you want. -- **Dynamic Configuration:** Easily add, remove, or disable Pi-holes through a simple `config.json` file. -- **Responsive Design:** The layout works on both desktop and mobile devices, stacking cards vertically on smaller screens. -- **Real-time Statistics:** The dashboard automatically refreshes every second. -- **Lightweight and Fast:** Built with Flask and vanilla JavaScript, Pi-Dash is fast and has minimal dependencies. -- **Dark Mode:** Automatically switches theme based on your system preferences. +- Multiple Pi-hole instances on one responsive dashboard +- Network-wide aggregate query, blocked, percentage, cache and forward counters +- Per-instance latency and health state +- Pi-hole blocking enabled/disabled state +- Optional live DNS query feed +- Lightweight in-browser query-rate sparklines with no database or history service +- Independent stats and query polling intervals +- Shared server-side cache to avoid duplicate Pi-hole API traffic from multiple browser tabs/devices +- Automatic session re-authentication +- Optional TLS verification per Pi-hole +- Environment-variable substitution for passwords and CA paths +- PWA support with network-fresh application shell updates +- Dark mode and mobile layout +- Docker images for amd64 and arm64 +- `/health` endpoint and Docker healthcheck +- No external font dependency; the dashboard works on an isolated LAN ![pi-dash-landscape](https://github.com/user-attachments/assets/a0e1fbef-279a-40df-9424-0cad50c31b50) +## Backward compatibility -Screenshot 2025-10-04 114711 +Existing `config.json` files continue to work without adding any of the new options. -## Configuration - -Before running the application, you need to create and configure the following files: +- If `refresh_interval` is absent, Pi-Dash keeps the historical 5000 ms code default. +- If `queries_refresh_interval` is absent, it inherits `refresh_interval`, preserving the old query-feed cadence. +- `verify_ssl` defaults to `false`, matching the previous behavior. +- `/data` keeps its original response format unless a caller explicitly requests the richer summary/query payload. +- All newly introduced configuration keys are optional. -### 1. `config.json` +## Configuration -This file manages your Pi-hole instances and dashboard settings. Example: +Create `config.json` from `config-example.json`. ```json { "base_path": "/", - "refresh_interval": 1000, + "refresh_interval": 2000, + "queries_refresh_interval": 3000, + "cache_ttl": 1000, "show_queries": false, + "show_network_summary": true, + "show_trends": true, "piholes": [ { "name": "Primary", - "address": "https://pi.hole/one", - "password": "your_app_password_here", + "address": "https://pi.hole", + "password": "${PIHOLE_PRIMARY_PASSWORD}", "enabled": true, - "link": true - }, - { - "name": "Secondary", - "address": "https://pi.hole/two", - "password": "your_app_password_here", - "enabled": true, - "link": false + "link": true, + "verify_ssl": false } ] } ``` -- **base_path**: (optional) The subpath where the application is hosted (e.g., `/pi-dash/`). Defaults to `/`. -- **refresh_interval**: (optional) How often the dashboard updates, in milliseconds (e.g., 1000 = 1 second). -- **show_queries**: (optional, default: `false`) When set to `true`, a subtle scrolling feed of recent DNS queries will appear from the bottom of the screen. Blocked domains are shown in red, allowed in green. -- **piholes**: List of Pi-hole instances. - - **name**: Display name for your Pi-hole (e.g., "Primary"). - - **address**: Full URL to your Pi-hole (e.g., "http://pi.hole"). - - **password**: Your Pi-hole API token/password. - - **enabled**: Set to `true` to display the Pi-hole on the dashboard, or `false` to hide it. - - **link**: (optional, default: `false`) When `true`, the display name becomes a clickable link that opens the Pi-hole admin in a new tab. +### Dashboard options + +| Option | Default | Purpose | +| --- | --- | --- | +| `base_path` | `/` | Subpath where Pi-Dash is hosted | +| `refresh_interval` | `5000` | Main statistics refresh interval in milliseconds | +| `queries_refresh_interval` | `refresh_interval` | Live query-feed refresh interval in milliseconds | +| `cache_ttl` | derived automatically | Shared backend cache lifetime in milliseconds | +| `show_queries` | `false` | Show the scrolling recent-query feed | +| `show_network_summary` | `true` | Show the combined network summary | +| `show_trends` | `true` | Show short in-memory query-rate sparklines | -### 2. `manifest.json` (Optional) +When `cache_ttl` is omitted, Pi-Dash chooses a short value based on the fastest configured polling interval. The automatic value is deliberately shorter than the polling cadence so caching cannot silently slow an explicitly configured refresh rate. -This file is for Progressive Web App (PWA) settings and icon. Example: +### Pi-hole options + +| Option | Default | Purpose | +| --- | --- | --- | +| `name` | required | Display name | +| `address` | required | Pi-hole base URL | +| `password` | required/empty | API password or `${ENV_NAME}` | +| `enabled` | `true` | Include the Pi-hole in the dashboard | +| `link` | `false` | Make the instance name open Pi-hole Admin | +| `verify_ssl` | `false` | `true`, `false`, or a CA bundle path | + +`verify_ssl` remains `false` by default because many LAN Pi-hole installations use self-signed certificates. If your Pi-hole has a valid certificate, set it to `true`. A custom CA bundle can also be supplied as a path. + +Passwords can be kept outside `config.json`: ```json -{ - "name": "Pi-Dash", - "short_name": "Pi-Dash", - "description": "A simple dashboard to monitor Pi-hole status.", - "start_url": "/", - "display": "standalone", - "background_color": "#111827", - "theme_color": "#06b6d4", - "icons": [ - { - "src": "https://pi.hole/admin/img/logo.svg", - "sizes": "512x512", - "type": "image/svg+xml" - } - ] -} +"password": "${PIHOLE_PRIMARY_PASSWORD}" ``` -- **icons.src**: Replace with a direct link to your Pi-hole logo or preferred icon. +Then provide `PIHOLE_PRIMARY_PASSWORD` through your shell or Docker environment. + +## Polling model + +Stats and recent queries are fetched separately. This means a typical configuration can update the visible counters every 2 seconds while fetching the query feed every 3 seconds. Pi-Dash also caches Pi-hole responses briefly in the server process, so opening the dashboard on multiple devices does not multiply upstream Pi-hole API traffic unnecessarily. ---- +The blocking enabled/disabled state changes much less frequently than query counters and is cached separately for 30 seconds. + +## Network summary semantics + +Pi-Dash only aggregates counters that can safely be summed across Pi-hole instances: queries, blocked queries, cached queries and forwarded queries. It deliberately does **not** sum active clients or unique domains because the same client/domain may appear on more than one redundant Pi-hole. ## Installation -### Docker - -1. **Docker Compose** - ```yaml - services: - pi-dash: - image: ghcr.io/surajverma/pi-dash:latest - container_name: pi-dash - ports: - - 5001:5001 - volumes: - - ./config.json:/app/config.json - - ./manifest.json:/app/manifest.json - # Assumes config.json and manifest.json are in the same folder as your compose.yml file - ``` -2. **Docker Run** - ```bash - docker run -d \ - --name=pi-dash \ - -p 5001:5001 \ - -v /path/to/pi-dash/config.json:/app/config.json \ - -v /path/to/pi-dash/manifest.json:/app/manifest.json \ # If you wish to edit the current manifest - ghcr.io/surajverma/pi-dash:latest - ``` - -### Native Install - -1. **Clone the repository:** - - ```bash - git clone https://github.com/surajverma/pi-dash.git - cd pi-dash - ``` - -2. **Install the dependencies:** - - ```bash - pip install -r requirements.txt - ``` - -3. **Start the app:** - To start the application, run the following command from the project's root directory: - ```bash - python proxy.py - ``` - -Then, open your web browser and navigate to `http://localhost:5001`. +### Docker Compose + +```yaml +services: + pi-dash: + image: ghcr.io/surajverma/pi-dash:latest + container_name: pi-dash + ports: + - 5001:5001 + environment: + PIHOLE_PRIMARY_PASSWORD: "your_app_password_here" + volumes: + - ./config.json:/app/config.json:ro + - ./manifest.json:/app/manifest.json:ro + restart: unless-stopped +``` + +The container runs Pi-Dash through Gunicorn and includes a healthcheck against `/health`. + +### Docker Run + +```bash +docker run -d \ + --name pi-dash \ + -p 5001:5001 \ + -e PIHOLE_PRIMARY_PASSWORD='your_app_password_here' \ + -v /path/to/config.json:/app/config.json:ro \ + -v /path/to/manifest.json:/app/manifest.json:ro \ + ghcr.io/surajverma/pi-dash:latest +``` + +### Native install + +```bash +git clone https://github.com/surajverma/pi-dash.git +cd pi-dash +cp config-example.json config.json +pip install -r requirements.txt +python proxy.py +``` + +Then open `http://localhost:5001`. + +`python proxy.py` remains available for simple native/LAN use. The Docker image uses Gunicorn for a more robust long-running server. + +## Health endpoint + +```text +GET /health +``` + +returns a small Pi-Dash process health response without contacting every Pi-hole. This makes it suitable for Docker, Uptime Kuma, Homepage, Homarr and similar monitoring tools. + +## Tests + +Tests do not require a live Pi-hole: + +```bash +cp config-example.json config.json +python -m unittest discover -s tests -v +``` + +They cover old-config fallback behavior, independent query polling, cache behavior, zero enabled Pi-holes, secret resolution, safe network aggregation, the legacy `/data` shape and `/health`. + +## PWA behavior + +Pi-Dash caches its application shell for offline/reload resilience, but API responses are never cached by the service worker. Navigation uses network-first behavior and static assets are refreshed in the background, reducing the chance of an upgraded container continuing to display an old dashboard bundle. ## Credits -Initial development of Pi-dash was done by [Codeloaf](https://github.com/codeloaf). It has since been transferred to this repository for ongoing maintenance, as the original author is not active on GitHub. + +Initial development of Pi-Dash was done by [Codeloaf](https://github.com/codeloaf). It has since been transferred to this repository for ongoing maintenance, as the original author is not active on GitHub. ## Disclaimer @@ -151,11 +191,12 @@ This project is not associated with the official [Pi-hole](https://pi-hole.net/) ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +MIT. See [LICENSE](LICENSE). ## Contributing -Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. +Contributions, suggestions and bug reports are welcome through GitHub issues and pull requests. ## Thank You + If you like my work, you can [buy me a coffee โ˜•](https://ko-fi.com/skv) From 95ea9c5ef130631824ddd0e81cd4b73d7df2bb5f Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:30:59 +0530 Subject: [PATCH 16/45] Keep future CSS builds self-contained --- src/tailwind.css | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/tailwind.css b/src/tailwind.css index 1935b7a..e63158e 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -2,9 +2,8 @@ @tailwind components; @tailwind utilities; -/* --- Your custom CSS below --- */ body { - font-family: 'Inter', sans-serif; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-tap-highlight-color: transparent; } html, body { @@ -17,7 +16,6 @@ html, body { display: inline-block; } - #background-queries { position: fixed; bottom: 0; left: 0; right: 0; top: auto; @@ -26,61 +24,41 @@ html, body { flex-direction: column; justify-content: flex-end; list-style: none; margin: 0; padding: 4px 6px 8px; - font-family: 'Inter', monospace; font-size: 10px; line-height: 1.25; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 10px; line-height: 1.25; pointer-events: none; overflow: hidden; z-index: 0; - -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 50%, rgba(0,0,0,0.5) 80%, rgba(0,0,0,0) 100%); mask-image: linear-gradient(to top, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 50%, rgba(0,0,0,0.5) 80%, rgba(0,0,0,0) 100%); - backdrop-filter: blur(0.5px); -webkit-backdrop-filter: blur(0.5px); } - #background-queries li { text-shadow: 0 1px 2px rgba(0,0,0,0.15); letter-spacing: 0.01em; } -@keyframes bqFadeIn { - to { opacity:0.9; transform:translateY(0); } -} - - @media (max-width: 640px) and (orientation: portrait) { - #background-queries { - height: 42vh; - max-height: 42vh; - } - + #background-queries { height: 42vh; max-height: 42vh; } body:has(#background-queries:not(:empty)) #main-wrapper { transform: translateY(-8vh); transition: transform 0.3s ease; } } - @media (max-width: 896px) and (orientation: landscape) { #background-queries { - height: 35vh; - max-height: 35vh; - font-size: 9px; - padding: 2px 4px 6px; - line-height: 1.2; + height: 35vh; max-height: 35vh; font-size: 9px; + padding: 2px 4px 6px; line-height: 1.2; } - body:has(#background-queries:not(:empty)) #main-wrapper { transform: translateY(-4vh); transition: transform 0.3s ease; } } - @media (min-width: 897px) and (max-width: 1024px) and (orientation: landscape) { #background-queries { - height: 28vh; - max-height: 28vh; - font-size: 9px; - padding: 2px 6px 6px; + height: 28vh; max-height: 28vh; font-size: 9px; padding: 2px 6px 6px; } -} \ No newline at end of file +} From 04251c3b1eff99780005cb971dd7cf1816a0ed4d Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:31:13 +0530 Subject: [PATCH 17/45] Extend CI to check frontend syntax and Tailwind build --- .github/workflows/test.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1213a3..c17fdd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,15 +11,34 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.13' cache: 'pip' - - name: Install dependencies + + - name: Install Python dependencies run: pip install -r requirements.txt + - name: Prepare test configuration run: cp config-example.json config.json + - name: Run regression tests run: python -m unittest discover -s tests -v + - name: Compile-check backend run: python -m py_compile proxy.py + + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'npm' + + - name: Install frontend dependencies + run: npm install + + - name: Syntax-check frontend + run: node --check js/app.js + + - name: Verify Tailwind build + run: npm run build:css From 5e5b31a2f76051e6e81943c13fd8dd5e342b2a68 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:32:17 +0530 Subject: [PATCH 18/45] Fix frontend CI setup for repository without lockfile --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c17fdd3..af7cc71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 'lts/*' - cache: 'npm' - name: Install frontend dependencies run: npm install From e291420e2d42fa020121d13e76de9c7af32b2caa Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:34:05 +0530 Subject: [PATCH 19/45] Coalesce concurrent query cache misses --- proxy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/proxy.py b/proxy.py index cf97ac2..8f1dd9d 100644 --- a/proxy.py +++ b/proxy.py @@ -174,6 +174,10 @@ def fetch_queries(length=50,force=False): c=_queries_cache.get(length) if not force and c and now-c['time'] Date: Tue, 8 Sep 2026 17:53:18 +0530 Subject: [PATCH 20/45] Keep Docker healthcheck valid with base path hosting --- proxy.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/proxy.py b/proxy.py index 8f1dd9d..bf741e6 100644 --- a/proxy.py +++ b/proxy.py @@ -216,4 +216,10 @@ def queries(): def health(): return jsonify({'status':'ok','enabled_piholes':len(enabled()),'cache_ttl_ms':get_cache_ttl()}) app.register_blueprint(bp,url_prefix=url_prefix) + +# Keep a root health endpoint for Docker/orchestrators even when the UI is +# hosted below a configured base_path such as /pi-dash/. +if url_prefix not in ('', '/'): + app.add_url_rule('/health','root_health',health) + if __name__=='__main__': app.run(host='0.0.0.0',port=5001) From ea37d910c7f49565f812185de71065a1f1533f82 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:54:19 +0530 Subject: [PATCH 21/45] Remove misleading latency and correct blocking health semantics --- proxy.py | 495 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 352 insertions(+), 143 deletions(-) diff --git a/proxy.py b/proxy.py index bf741e6..838414c 100644 --- a/proxy.py +++ b/proxy.py @@ -1,7 +1,11 @@ from flask import Flask, jsonify, send_from_directory, request, Blueprint from markupsafe import escape import requests -import os, json, concurrent.futures, threading, time +import os +import json +import concurrent.futures +import threading +import time from urllib.parse import urlparse NO_PASSWORD = "NO_PASSWORD" @@ -9,217 +13,422 @@ DEFAULT_REQUEST_TIMEOUT = 10 DEFAULT_CACHE_TTL = 1000 DEFAULT_BLOCKING_TTL = 30000 -SLOW_RESPONSE_MS = 500 -BLOCKED_STATUSES = {'GRAVITY','REGEX','DENYLIST','EXTERNAL_BLOCKED_IP','EXTERNAL_BLOCKED_NULL','EXTERNAL_BLOCKED_NXRA','GRAVITY_CNAME','REGEX_CNAME','DENYLIST_CNAME','DBBUSY','SPECIAL_DOMAIN','EXTERNAL_BLOCKED_EDE15'} +BLOCKED_STATUSES = { + 'GRAVITY', 'REGEX', 'DENYLIST', 'EXTERNAL_BLOCKED_IP', + 'EXTERNAL_BLOCKED_NULL', 'EXTERNAL_BLOCKED_NXRA', 'GRAVITY_CNAME', + 'REGEX_CNAME', 'DENYLIST_CNAME', 'DBBUSY', 'SPECIAL_DOMAIN', + 'EXTERNAL_BLOCKED_EDE15', +} app = Flask(__name__) APP_ROOT = os.path.dirname(os.path.realpath(__file__)) + def _load_json(name): - with open(os.path.join(APP_ROOT, name), encoding='utf-8') as f: return json.load(f) + with open(os.path.join(APP_ROOT, name), encoding='utf-8') as handle: + return json.load(handle) + + config = _load_json('config.json') manifest_data = _load_json('manifest.json') -with open(os.path.join(APP_ROOT,'index.html'),encoding='utf-8') as f: index_template=f.read() -with open(os.path.join(APP_ROOT,'sw.js'),encoding='utf-8') as f: sw_template=f.read() +with open(os.path.join(APP_ROOT, 'index.html'), encoding='utf-8') as handle: + index_template = handle.read() +with open(os.path.join(APP_ROOT, 'sw.js'), encoding='utf-8') as handle: + sw_template = handle.read() + +base_path_config = config.get('base_path', '/') +url_prefix = base_path_config[:-1] if base_path_config != '/' and base_path_config.endswith('/') else base_path_config +html_base = base_path_config if base_path_config.endswith('/') else base_path_config + '/' +bp = Blueprint('pi-dash', __name__) +pihole_sessions = {} +_sessions_lock = threading.Lock() +_cache_lock = threading.Lock() +_stats_refresh_lock = threading.Lock() +_queries_refresh_lock = threading.Lock() +_blocking_lock = threading.Lock() +_stats_cache = {'time': 0.0, 'data': None} +_queries_cache = {} +_blocking_cache = {} -base_path_config=config.get('base_path','/') -url_prefix=base_path_config[:-1] if base_path_config!='/' and base_path_config.endswith('/') else base_path_config -html_base=base_path_config if base_path_config.endswith('/') else base_path_config+'/' -bp=Blueprint('pi-dash',__name__) -pihole_sessions={}; _sessions_lock=threading.Lock(); _cache_lock=threading.Lock(); _stats_refresh_lock=threading.Lock(); _queries_refresh_lock=threading.Lock(); _blocking_lock=threading.Lock() -_stats_cache={'time':0.0,'data':None}; _queries_cache={}; _blocking_cache={} def _int(value, default, minimum=100): try: - value=int(value); return value if value>=minimum else default - except (TypeError,ValueError): return default + value = int(value) + return value if value >= minimum else default + except (TypeError, ValueError): + return default + + +def get_refresh_interval(): + return _int(config.get('refresh_interval'), DEFAULT_REFRESH_INTERVAL) + + +def get_queries_refresh_interval(): + # An old configuration keeps its original query refresh cadence. + return _int(config.get('queries_refresh_interval'), get_refresh_interval()) + -def get_refresh_interval(): return _int(config.get('refresh_interval'),DEFAULT_REFRESH_INTERVAL) -def get_queries_refresh_interval(): return _int(config.get('queries_refresh_interval'),get_refresh_interval()) def get_cache_ttl(): - if 'cache_ttl' in config: return _int(config.get('cache_ttl'),DEFAULT_CACHE_TTL,0) - return min(DEFAULT_CACHE_TTL,max(100,min(get_refresh_interval(),get_queries_refresh_interval())//2)) + if 'cache_ttl' in config: + return _int(config.get('cache_ttl'), DEFAULT_CACHE_TTL, 0) + return min(DEFAULT_CACHE_TTL, max(100, min(get_refresh_interval(), get_queries_refresh_interval()) // 2)) + + def resolve_secret(value): - if isinstance(value,str) and value.startswith('${') and value.endswith('}'): - return os.environ.get(value[2:-1],value) + if isinstance(value, str) and value.startswith('${') and value.endswith('}'): + return os.environ.get(value[2:-1], value) return value -def get_verify_setting(p): - v=p.get('verify_ssl',False) - if isinstance(v,str): - if v.lower()=='true': return True - if v.lower()=='false': return False - return resolve_secret(v) - return bool(v) -def headers(sid): return {} if sid==NO_PASSWORD else {'X-FTL-SID':sid} - -def authenticate_and_get_sid(p): - address=p['address'].rstrip('/') + + +def get_verify_setting(pihole): + value = pihole.get('verify_ssl', False) + if isinstance(value, str): + if value.lower() == 'true': + return True + if value.lower() == 'false': + return False + return resolve_secret(value) + return bool(value) + + +def headers(sid): + return {} if sid == NO_PASSWORD else {'X-FTL-SID': sid} + + +def authenticate_and_get_sid(pihole): + address = pihole['address'].rstrip('/') try: - r=requests.post(address+'/api/auth',json={'password':resolve_secret(p.get('password',''))},timeout=DEFAULT_REQUEST_TIMEOUT,verify=get_verify_setting(p)) - if r.status_code==200: - d=r.json(); sid=d.get('session',{}).get('sid') - if sid: return sid - if d.get('session',{}).get('message')=='no password set': return NO_PASSWORD + response = requests.post( + address + '/api/auth', + json={'password': resolve_secret(pihole.get('password', ''))}, + timeout=DEFAULT_REQUEST_TIMEOUT, + verify=get_verify_setting(pihole), + ) + if response.status_code == 200: + data = response.json() + sid = data.get('session', {}).get('sid') + if sid: + return sid + if data.get('session', {}).get('message') == 'no password set': + return NO_PASSWORD return None - except requests.exceptions.RequestException: return None + except requests.exceptions.RequestException: + return None + -def get_sid(p,force=False): - name=p['name'] +def get_sid(pihole, force=False): + name = pihole['name'] with _sessions_lock: - if force: pihole_sessions.pop(name,None) - sid=pihole_sessions.get(name) - if sid: return sid - sid=authenticate_and_get_sid(p) + if force: + pihole_sessions.pop(name, None) + sid = pihole_sessions.get(name) if sid: - with _sessions_lock: pihole_sessions[name]=sid + return sid + sid = authenticate_and_get_sid(pihole) + if sid: + with _sessions_lock: + pihole_sessions[name] = sid return sid -def pihole_get(p,path,params=None): - sid=get_sid(p) - if not sid: raise RuntimeError('authentication failed') - args={'headers':headers(sid),'params':params,'timeout':DEFAULT_REQUEST_TIMEOUT,'verify':get_verify_setting(p)} - r=requests.get(p['address'].rstrip('/')+path,**args) - if r.status_code==401 and sid!=NO_PASSWORD: - sid=get_sid(p,True) - if not sid: raise RuntimeError('re-authentication failed') - args['headers']=headers(sid); r=requests.get(p['address'].rstrip('/')+path,**args) - r.raise_for_status(); return r +def pihole_get(pihole, path, params=None): + sid = get_sid(pihole) + if not sid: + raise RuntimeError('authentication failed') + args = { + 'headers': headers(sid), 'params': params, + 'timeout': DEFAULT_REQUEST_TIMEOUT, + 'verify': get_verify_setting(pihole), + } + url = pihole['address'].rstrip('/') + path + response = requests.get(url, **args) + if response.status_code == 401 and sid != NO_PASSWORD: + sid = get_sid(pihole, True) + if not sid: + raise RuntimeError('re-authentication failed') + args['headers'] = headers(sid) + response = requests.get(url, **args) + response.raise_for_status() + return response + + +# -- Frontend routes -- @bp.route('/') def index(): - icon=(manifest_data.get('icons') or [{}])[0].get('src','') - return index_template.replace('',f'\n ').replace('{{ICON_URL}}',escape(icon)) + icon = (manifest_data.get('icons') or [{}])[0].get('src', '') + return index_template.replace('', f'\n ').replace('{{ICON_URL}}', escape(icon)) + + @bp.route('/manifest.json') def serve_manifest(): - d=manifest_data.copy(); d['start_url']=html_base; return jsonify(d) + data = manifest_data.copy() + data['start_url'] = html_base + return jsonify(data) + + @bp.route('/sw.js') -def serve_sw(): return sw_template.replace('{{CACHE_URL}}',html_base),200,{'Content-Type':'application/javascript','Cache-Control':'no-cache'} +def serve_sw(): + return sw_template.replace('{{CACHE_URL}}', html_base), 200, {'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache'} + + @bp.route('/css/') -def send_css(path): return send_from_directory(os.path.join(APP_ROOT,'css'),path) +def send_css(path): + return send_from_directory(os.path.join(APP_ROOT, 'css'), path) + + @bp.route('/js/') -def send_js(path): return send_from_directory(os.path.join(APP_ROOT,'js'),path) +def send_js(path): + return send_from_directory(os.path.join(APP_ROOT, 'js'), path) + + @bp.route('/favicon.ico') -def favicon(): return '',204 +def favicon(): + return '', 204 + + +# -- API helpers -- +def enabled(): + return [p for p in config.get('piholes', []) if p.get('enabled', True)] + -def enabled(): return [p for p in config.get('piholes',[]) if p.get('enabled',True)] def filtered_config(): - out=[] - for p in enabled(): - item={'name':p['name'],'enabled':True,'link':p.get('link',False)} - if item['link']: item['address']=p['address'] + out = [] + for pihole in enabled(): + item = {'name': pihole['name'], 'enabled': True, 'link': pihole.get('link', False)} + if item['link']: + item['address'] = pihole['address'] out.append(item) - return {'refresh_interval':get_refresh_interval(),'queries_refresh_interval':get_queries_refresh_interval(),'show_queries':config.get('show_queries',False),'show_network_summary':config.get('show_network_summary',True),'show_trends':config.get('show_trends',True),'piholes':out} + return { + 'refresh_interval': get_refresh_interval(), + 'queries_refresh_interval': get_queries_refresh_interval(), + 'show_queries': config.get('show_queries', False), + 'show_network_summary': config.get('show_network_summary', True), + 'show_trends': config.get('show_trends', True), + 'piholes': out, + } + + +def normalize_blocking(value): + """Pi-hole v6 returns enabled/disabled strings; accept booleans as well.""" + if value is True or value is False: + return value + if isinstance(value, str): + if value.lower() in ('enabled', 'on', 'true'): + return True + if value.lower() in ('disabled', 'off', 'false'): + return False + return None -def get_blocking(p): - name=p['name']; now=time.monotonic() + +def get_blocking(pihole): + name = pihole['name'] + now = time.monotonic() with _cache_lock: - c=_blocking_cache.get(name) - if c and now-c['time']=SLOW_RESPONSE_MS else 'healthy'} - return name,d - except RuntimeError as e: return name,{'error':str(e),'_pi_dash':{'health':'auth_error'}} - except Exception as e: return name,{'error':str(e),'_pi_dash':{'health':'unreachable'}} + data = pihole_get(pihole, '/api/stats/summary').json() + blocking = get_blocking(pihole) + data['_pi_dash'] = { + 'blocking': blocking, + 'health': 'blocking_disabled' if blocking is False else 'healthy', + } + return name, data + except RuntimeError as exc: + return name, {'error': str(exc), '_pi_dash': {'health': 'auth_error'}} + except (requests.exceptions.RequestException, ValueError) as exc: + return name, {'error': str(exc), '_pi_dash': {'health': 'unreachable'}} + def _stats_uncached(): - items=enabled(); results={} - if not items: return results - with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(items),10)) as ex: - for f in concurrent.futures.as_completed([ex.submit(fetch_one,p) for p in items]): - n,d=f.result(); results[n]=d + items = enabled() + results = {} + if not items: + return results + with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(items), 10)) as executor: + futures = [executor.submit(fetch_one, pihole) for pihole in items] + for future in concurrent.futures.as_completed(futures): + name, data = future.result() + results[name] = data return results + def fetch_stats(force=False): - ttl=get_cache_ttl()/1000; now=time.monotonic() + ttl = get_cache_ttl() / 1000 + now = time.monotonic() with _cache_lock: - if not force and _stats_cache['data'] is not None and now-_stats_cache['time'] Date: Tue, 8 Sep 2026 18:55:22 +0530 Subject: [PATCH 22/45] Add testable query grouping and health presentation helpers --- js/dashboard-core.js | 160 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 js/dashboard-core.js diff --git a/js/dashboard-core.js b/js/dashboard-core.js new file mode 100644 index 0000000..364b573 --- /dev/null +++ b/js/dashboard-core.js @@ -0,0 +1,160 @@ +/* Pure dashboard helpers shared by the browser and Node regression tests. */ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.PiDashCore = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function finite(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; + } + + function summaryStats(raw) { + const q = raw?.queries || {}; + const clients = raw?.clients || {}; + const gravity = raw?.gravity || {}; + return { + total: finite(q.total), blocked: finite(q.blocked), + percentage: finite(q.percent_blocked), clients: finite(clients.active), + rate: finite(q.frequency), cached: finite(q.cached), + forwarded: finite(q.forwarded), unique: finite(q.unique_domains), + domains: finite(gravity.domains_being_blocked), + }; + } + + function formatRate(rate) { + const value = Math.max(0, finite(rate)); + return value < 1 ? `${(value * 60).toFixed(1)}/min` : `${value.toFixed(1)}/sec`; + } + + function healthPresentation(meta = {}, hasError = false) { + if (hasError || meta.health === 'auth_error') { + if (meta.health === 'auth_error') return { state: 'danger', text: 'Auth failed' }; + return { state: 'danger', text: 'Offline' }; + } + if (meta.health === 'unreachable') return { state: 'danger', text: 'Offline' }; + if (meta.blocking === false || meta.health === 'blocking_disabled') { + return { state: 'warning', text: 'Blocking OFF' }; + } + if (meta.blocking === true) return { state: 'success', text: 'Blocking ON' }; + return { state: 'neutral', text: 'Online ยท blocking unknown' }; + } + + function networkStatus(summary) { + const total = finite(summary?.instances); + const reporting = finite(summary?.contributing_instances, total - finite(summary?.offline_instances)); + if (!total) return 'No active Pi-holes'; + const parts = [`${reporting} of ${total} reporting`]; + if (summary?.blocking_disabled_instances) parts.push(`${summary.blocking_disabled_instances} blocking off`); + if (summary?.offline_instances) parts.push(`${summary.offline_instances} offline`); + if (summary?.blocking_unknown_instances) parts.push(`${summary.blocking_unknown_instances} blocking unknown`); + if (!summary?.partial && !summary?.blocking_disabled_instances && !summary?.offline_instances && !summary?.blocking_unknown_instances) { + return `${total} healthy`; + } + return parts.join(' ยท '); + } + + function queryIdentity(query) { + const id = query.id; + if (id !== null && id !== undefined && id !== '' && Number.isFinite(Number(id))) { + return `id:${String(id)}`; + } + // Fallback for older APIs without IDs. Include type and client so that + // simultaneous A/AAAA or different-client requests are not conflated. + return `time:${query.time ?? query.timestamp ?? ''}|${query.type ?? ''}|${query.client ?? ''}|${query.domain ?? ''}|${query.blocked ? 1 : 0}`; + } + + function queryTimestamp(query) { + return finite(query.time ?? query.timestamp, 0); + } + + function queryKey(query) { + return JSON.stringify([query.piholeName, String(query.domain || '').toLowerCase(), Boolean(query.blocked)]); + } + + function queryLabel(query) { + return `[${query.piholeName}] ${query.domain || ''}`; + } + + function groupConsecutiveQueries(events) { + const groups = []; + for (const event of events) { + const key = queryKey(event); + const previous = groups[groups.length - 1]; + if (previous && previous.key === key) { + previous.count += 1; + } else { + groups.push({ key, label: queryLabel(event), blocked: Boolean(event.blocked), count: 1 }); + } + } + return groups; + } + + function mergeConsecutiveGroups(existing, incoming, maxRows = 100) { + const rows = existing.map(row => ({ ...row })); + for (const group of incoming) { + const last = rows[rows.length - 1]; + if (last && last.key === group.key) last.count += group.count; + else rows.push({ ...group }); + } + return rows.slice(-maxRows); + } + + function createQueryTracker() { + return { cursors: new Map(), seen: new Map() }; + } + + function collectNewQueries(tracker, allQueries, maxSeen = 1000) { + const incoming = []; + let order = 0; + for (const [piholeName, queries] of Object.entries(allQueries || {})) { + if (!Array.isArray(queries)) continue; + let cursor = tracker.cursors.get(piholeName); + if (!cursor) cursor = { id: null, time: -Infinity }; + let seen = tracker.seen.get(piholeName); + if (!seen) seen = new Map(); + const valid = queries.filter(q => q && typeof q === 'object'); + const ids = valid.map(q => Number(q.id)).filter((id, i) => valid[i]?.id != null && Number.isFinite(id)); + const newestTime = Math.max(cursor.time, ...valid.map(queryTimestamp)); + // A Pi-hole database can restart its ID sequence. A newer timestamp + // with an entirely lower ID range indicates a reset, not old traffic. + if (cursor.id !== null && ids.length && Math.max(...ids) < cursor.id && newestTime > cursor.time) { + cursor = { id: null, time: cursor.time }; + seen = new Map(); + } + let maxId = cursor.id; + let maxTime = cursor.time; + for (const query of valid) { + const identity = queryIdentity(query); + const timestamp = queryTimestamp(query); + const id = query.id != null && query.id !== '' && Number.isFinite(Number(query.id)) ? Number(query.id) : null; + const alreadySeen = seen.has(identity); + const isNew = id !== null + ? (cursor.id === null || id > cursor.id) + : timestamp > cursor.time; + if (isNew && !alreadySeen) incoming.push({ ...query, piholeName, __order: order++, __time: timestamp, __id: id ?? 0 }); + seen.delete(identity); + seen.set(identity, true); + if (id !== null && (maxId === null || id > maxId)) maxId = id; + maxTime = Math.max(maxTime, timestamp); + } + while (seen.size > maxSeen) seen.delete(seen.keys().next().value); + tracker.cursors.set(piholeName, { id: maxId, time: maxTime }); + tracker.seen.set(piholeName, seen); + } + // IDs belong to individual databases; timestamps provide cross-instance + // ordering, with source IDs used only to break ties within one instance. + incoming.sort((a, b) => a.__time - b.__time || + (a.piholeName === b.piholeName ? a.__id - b.__id : 0) || a.__order - b.__order); + return incoming; + } + + return { + summaryStats, formatRate, healthPresentation, networkStatus, + queryIdentity, queryTimestamp, queryKey, queryLabel, + groupConsecutiveQueries, mergeConsecutiveGroups, + createQueryTracker, collectNewQueries, + }; +}); From cc887f57c5b5619633e398c3954cdb407777a123 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:09:13 +0530 Subject: [PATCH 23/45] Correct query identity tracking and restore consecutive grouping helpers --- js/dashboard-core.js | 50 ++++++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/js/dashboard-core.js b/js/dashboard-core.js index 364b573..69afac4 100644 --- a/js/dashboard-core.js +++ b/js/dashboard-core.js @@ -31,8 +31,7 @@ function healthPresentation(meta = {}, hasError = false) { if (hasError || meta.health === 'auth_error') { - if (meta.health === 'auth_error') return { state: 'danger', text: 'Auth failed' }; - return { state: 'danger', text: 'Offline' }; + return { state: 'danger', text: meta.health === 'auth_error' ? 'Auth failed' : 'Offline' }; } if (meta.health === 'unreachable') return { state: 'danger', text: 'Offline' }; if (meta.blocking === false || meta.health === 'blocking_disabled') { @@ -56,18 +55,17 @@ return parts.join(' ยท '); } + function queryTimestamp(query) { + return finite(query.time ?? query.timestamp, 0); + } + function queryIdentity(query) { const id = query.id; if (id !== null && id !== undefined && id !== '' && Number.isFinite(Number(id))) { return `id:${String(id)}`; } - // Fallback for older APIs without IDs. Include type and client so that - // simultaneous A/AAAA or different-client requests are not conflated. - return `time:${query.time ?? query.timestamp ?? ''}|${query.type ?? ''}|${query.client ?? ''}|${query.domain ?? ''}|${query.blocked ? 1 : 0}`; - } - - function queryTimestamp(query) { - return finite(query.time ?? query.timestamp, 0); + const client = typeof query.client === 'object' ? query.client?.ip : query.client; + return JSON.stringify(['time', query.time ?? query.timestamp ?? '', query.type ?? '', client ?? '', query.domain ?? '', Boolean(query.blocked)]); } function queryKey(query) { @@ -83,11 +81,8 @@ for (const event of events) { const key = queryKey(event); const previous = groups[groups.length - 1]; - if (previous && previous.key === key) { - previous.count += 1; - } else { - groups.push({ key, label: queryLabel(event), blocked: Boolean(event.blocked), count: 1 }); - } + if (previous && previous.key === key) previous.count += 1; + else groups.push({ key, label: queryLabel(event), blocked: Boolean(event.blocked), count: 1 }); } return groups; } @@ -111,15 +106,12 @@ let order = 0; for (const [piholeName, queries] of Object.entries(allQueries || {})) { if (!Array.isArray(queries)) continue; - let cursor = tracker.cursors.get(piholeName); - if (!cursor) cursor = { id: null, time: -Infinity }; - let seen = tracker.seen.get(piholeName); - if (!seen) seen = new Map(); + let cursor = tracker.cursors.get(piholeName) || { id: null, time: -Infinity }; + let seen = tracker.seen.get(piholeName) || new Map(); const valid = queries.filter(q => q && typeof q === 'object'); - const ids = valid.map(q => Number(q.id)).filter((id, i) => valid[i]?.id != null && Number.isFinite(id)); - const newestTime = Math.max(cursor.time, ...valid.map(queryTimestamp)); - // A Pi-hole database can restart its ID sequence. A newer timestamp - // with an entirely lower ID range indicates a reset, not old traffic. + const ids = valid.filter(q => q.id != null && q.id !== '' && Number.isFinite(Number(q.id))).map(q => Number(q.id)); + const newestTime = valid.reduce((max, q) => Math.max(max, queryTimestamp(q)), cursor.time); + // A newer snapshot whose entire ID range is lower may indicate a DB reset. if (cursor.id !== null && ids.length && Math.max(...ids) < cursor.id && newestTime > cursor.time) { cursor = { id: null, time: cursor.time }; seen = new Map(); @@ -130,11 +122,10 @@ const identity = queryIdentity(query); const timestamp = queryTimestamp(query); const id = query.id != null && query.id !== '' && Number.isFinite(Number(query.id)) ? Number(query.id) : null; - const alreadySeen = seen.has(identity); - const isNew = id !== null - ? (cursor.id === null || id > cursor.id) - : timestamp > cursor.time; - if (isNew && !alreadySeen) incoming.push({ ...query, piholeName, __order: order++, __time: timestamp, __id: id ?? 0 }); + const isNew = id !== null ? (cursor.id === null || id > cursor.id) : timestamp > cursor.time; + if (isNew && !seen.has(identity)) { + incoming.push({ ...query, piholeName, __order: order++, __time: timestamp, __id: id ?? 0 }); + } seen.delete(identity); seen.set(identity, true); if (id !== null && (maxId === null || id > maxId)) maxId = id; @@ -144,8 +135,7 @@ tracker.cursors.set(piholeName, { id: maxId, time: maxTime }); tracker.seen.set(piholeName, seen); } - // IDs belong to individual databases; timestamps provide cross-instance - // ordering, with source IDs used only to break ties within one instance. + // IDs are local to each Pi-hole database, never a cross-instance clock. incoming.sort((a, b) => a.__time - b.__time || (a.piholeName === b.piholeName ? a.__id - b.__id : 0) || a.__order - b.__order); return incoming; @@ -157,4 +147,4 @@ groupConsecutiveQueries, mergeConsecutiveGroups, createQueryTracker, collectNewQueries, }; -}); +}); \ No newline at end of file From 156dfcb7c18bff272f4a4bc5093429dd499c38bf Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:09:59 +0530 Subject: [PATCH 24/45] Add compact mobile cards and restore grouped foreground-only query feed --- js/app.js | 394 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 339 insertions(+), 55 deletions(-) diff --git a/js/app.js b/js/app.js index 522b2cb..1c30b28 100644 --- a/js/app.js +++ b/js/app.js @@ -1,59 +1,343 @@ document.addEventListener('DOMContentLoaded', () => { + 'use strict'; + const core = window.PiDashCore; + if (!core) throw new Error('Pi-Dash core is missing'); + + const MAX_ROWS = 100; + const MAX_PENDING = 1000; + const MAX_TREND_POINTS = 30; + const metrics = [ + ['total', 'Total Queries', 'text-blue-500'], + ['blocked', 'Queries Blocked', 'text-red-500'], + ['percentage', 'Percent Blocked', 'text-yellow-600 dark:text-yellow-500'], + ['cache', 'Cached / Forwarded', 'text-indigo-500'], + ['unique', 'Unique Domains', 'text-orange-500'], + ['clients', 'Active Clients', 'text-purple-500'], + ['domains', 'Domains on Lists', 'text-green-600 dark:text-green-500'], + ]; + const cards = new Map(); + const trendHistory = new Map(); + const queryTracker = core.createQueryTracker(); + const queryRows = []; + let pendingQueries = []; + let appConfig = null; let statsTimer = null, queriesTimer = null, stalenessTimer = null; - let appConfig = null, statsFetching = false, queriesFetching = false, lastUpdateTime = null, queryFeedPaused = false; - const lastCursorByPihole = {}, trendHistory = {}, MAX_TREND_POINTS = 30; - - async function fetchData(url) { - const response = await fetch(url, { cache: 'no-store' }); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - return response.json(); - } - function escapeHtml(value) { return String(value).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",'''); } - function summaryStats(raw) { - if (!raw || !raw.queries) return { total:0,blocked:0,percentage:0,clients:0,rate:0,cached:0,forwarded:0,unique:0,domains:0 }; - const q=raw.queries||{}, clients=raw.clients||{}, gravity=raw.gravity||{}; - return { total:Number(q.total)||0, blocked:Number(q.blocked)||0, percentage:Number(q.percent_blocked)||0, clients:Number(clients.active)||0, rate:Number(q.frequency)||0, cached:Number(q.cached)||0, forwarded:Number(q.forwarded)||0, unique:Number(q.unique_domains)||0, domains:Number(gravity.domains_being_blocked)||0 }; - } - function healthPresentation(meta={}, hasError=false) { - if (hasError || meta.health==='unreachable' || meta.health==='auth_error') return {dot:'bg-red-500',text:meta.health==='auth_error'?'Auth failed':'Offline',textClass:'text-red-500'}; - if (meta.blocking===false) return {dot:'bg-yellow-500',text:'Blocking OFF',textClass:'text-yellow-600 dark:text-yellow-500'}; - if (meta.health==='slow') return {dot:'bg-yellow-500',text:'Slow',textClass:'text-yellow-600 dark:text-yellow-500'}; - return {dot:'bg-green-500',text:meta.blocking===true?'Blocking ON':'Online',textClass:'text-green-600 dark:text-green-500'}; - } - function addTrend(name,value) { (trendHistory[name] ||= []).push(value); if (trendHistory[name].length>MAX_TREND_POINTS) trendHistory[name].shift(); } - function renderSparkline(name,el) { - if (!el || !appConfig.show_trends) return; - const values=trendHistory[name]||[]; if(values.length<2){el.innerHTML='';return;} - const min=Math.min(...values), max=Math.max(...values), range=max-min||1; - const points=values.map((v,i)=>`${(i/(values.length-1))*100},${24-((v-min)/range)*20}`).join(' '); - el.innerHTML=``; - } - function updatePiholeUI(name,raw) { - const section=document.getElementById(`pihole-${name}-section`); if(!section)return; - const meta=raw?._pi_dash||{}, hasError=!!raw?.error, status=healthPresentation(meta,hasError); - const dot=section.querySelector('.status-dot'), health=section.querySelector('.pihole-health'), latency=section.querySelector('.pihole-latency'); - dot.className=`status-dot ${status.dot}`; health.className=`pihole-health text-xs ${status.textClass}`; health.textContent=status.text; - latency.textContent=Number.isFinite(Number(meta.latency_ms))?`${Number(meta.latency_ms).toFixed(0)} ms`:'-- ms'; - if(hasError){ section.querySelectorAll('[data-metric]').forEach(el=>el.textContent=el.dataset.metric==='percent'?'--%':'--'); section.querySelector('.pihole-cache').textContent='-- / --'; section.querySelector('.pihole-rate').textContent='(--/sec)'; return; } - const s=summaryStats(raw); let rateValue,rateUnit; - if(s.rate<1){rateValue=(s.rate*60).toFixed(1);rateUnit='/min';}else{rateValue=s.rate.toFixed(1);rateUnit='/sec';} - section.querySelector('.pihole-rate').textContent=`(${rateValue}${rateUnit})`; - section.querySelector('.pihole-total').textContent=s.total.toLocaleString(); section.querySelector('.pihole-blocked').textContent=s.blocked.toLocaleString(); section.querySelector('.pihole-percent').textContent=`${s.percentage.toFixed(1)}%`; section.querySelector('.pihole-clients').textContent=s.clients.toLocaleString(); section.querySelector('.pihole-cache').textContent=`${s.cached.toLocaleString()} / ${s.forwarded.toLocaleString()}`; section.querySelector('.pihole-unique').textContent=s.unique.toLocaleString(); section.querySelector('.pihole-domains').textContent=s.domains.toLocaleString(); - addTrend(name,s.rate); renderSparkline(name,section.querySelector('.pihole-sparkline')); + let statsController = null, queriesController = null, initController = null; + let statsFetching = false, queriesFetching = false; + let lastUpdateTime = null, queryFeedPaused = false; + let foregroundGeneration = 0; + + const main = document.querySelector('main'); + const network = document.getElementById('network-summary'); + const queryContainer = document.getElementById('background-queries'); + const queryPanel = document.getElementById('query-panel'); + const queryToggle = document.getElementById('query-toggle'); + const pauseButton = document.getElementById('query-pause'); + const timestamp = document.getElementById('last-updated'); + const number = value => core.summaryStats({ queries: { total: value } }).total.toLocaleString(); + + function visible() { + return !document.hidden && navigator.onLine !== false; + } + + async function fetchData(url, signal) { + const response = await fetch(url, { cache: 'no-store', signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data?.error) throw new Error(data.error); + return data; + } + + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function addMetric(parent, key, label, color, compact = false) { + const row = element('div', compact ? 'mobile-metric' : 'metric-row'); + const caption = element('span', 'metric-label', label); + const value = element('span', `metric-value ${color}`, '--'); + value.dataset.value = key; + row.append(caption, value); + parent.append(row); + } + + function safeAdminUrl(address) { + try { + const url = new URL(address); + if (!['http:', 'https:'].includes(url.protocol)) return null; + url.pathname = url.pathname.replace(/\/$/, '') + '/admin'; + url.search = ''; + url.hash = ''; + return url.href; + } catch (_) { return null; } + } + + function createCard(pihole, index) { + const section = element('section', 'instance-card'); + const heading = element('div', 'instance-heading'); + const identity = element('div', 'instance-identity'); + const nameLine = element('h2', 'instance-name'); + const url = pihole.link ? safeAdminUrl(pihole.address) : null; + const name = element(url ? 'a' : 'span', '', pihole.name); + if (url) { + name.href = url; + name.target = '_blank'; + name.rel = 'noopener noreferrer'; + name.setAttribute('aria-label', `Open ${pihole.name} Pi-hole Admin`); + } + const rate = element('span', 'pihole-rate', '(--/sec)'); + nameLine.append(name, ' ', rate); + const healthLine = element('div', 'instance-health'); + const dot = element('span', 'status-dot status-neutral'); + const health = element('span', 'pihole-health', 'Checking'); + healthLine.append(dot, health); + identity.append(nameLine, healthLine); + const sparkline = element('div', 'pihole-sparkline'); + sparkline.setAttribute('aria-hidden', 'true'); + heading.append(identity, sparkline); + section.append(heading); + + const compact = element('div', 'mobile-instance-summary'); + addMetric(compact, 'total', 'Queries', 'text-blue-500', true); + addMetric(compact, 'blocked', 'Blocked', 'text-red-500', true); + addMetric(compact, 'percentage', 'Blocked %', 'text-yellow-600 dark:text-yellow-500', true); + section.append(compact); + + const details = element('div', 'instance-details'); + details.id = `instance-details-${index}`; + for (const [key, label, color] of metrics) addMetric(details, key, label, color); + section.append(details); + const toggle = element('button', 'instance-toggle', 'Show details'); + toggle.type = 'button'; + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-controls', details.id); + toggle.addEventListener('click', () => { + const expanded = section.classList.toggle('is-expanded'); + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.textContent = expanded ? 'Hide details' : 'Show details'; + }); + section.append(toggle); + cards.set(pihole.name, section); + return section; + } + + function renderSparkline(name, section, rate) { + const target = section.querySelector('.pihole-sparkline'); + if (!appConfig.show_trends) { target.replaceChildren(); return; } + const history = trendHistory.get(name) || []; + history.push(rate); + if (history.length > MAX_TREND_POINTS) history.shift(); + trendHistory.set(name, history); + if (history.length < 2) return; + const min = Math.min(...history), max = Math.max(...history), range = max - min || 1; + const points = history.map((v, i) => `${i / (history.length - 1) * 100},${24 - (v - min) / range * 20}`).join(' '); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 100 28'); + svg.setAttribute('preserveAspectRatio', 'none'); + const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + line.setAttribute('points', points); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', 'currentColor'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('vector-effect', 'non-scaling-stroke'); + svg.append(line); + target.replaceChildren(svg); + } + + function updatePiholeUI(name, raw) { + const section = cards.get(name); + if (!section) return; + const meta = raw?._pi_dash || {}; + const failed = !raw || !!raw.error || !raw.queries; + const status = core.healthPresentation(meta, failed); + section.querySelector('.status-dot').className = `status-dot status-${status.state}`; + const health = section.querySelector('.pihole-health'); + health.className = `pihole-health status-text-${status.state}`; + health.textContent = status.text; + section.querySelector('.pihole-rate').textContent = failed ? '(--/sec)' : `(${core.formatRate(core.summaryStats(raw).rate)})`; + const stats = core.summaryStats(failed ? null : raw); + const values = failed ? {} : { + total: number(stats.total), blocked: number(stats.blocked), + percentage: `${stats.percentage.toFixed(1)}%`, + cache: `${number(stats.cached)} / ${number(stats.forwarded)}`, + unique: number(stats.unique), clients: number(stats.clients), domains: number(stats.domains), + }; + section.querySelectorAll('[data-value]').forEach(node => { + node.textContent = values[node.dataset.value] ?? (node.dataset.value === 'percentage' ? '--%' : '--'); + }); + if (failed) { section.querySelector('.pihole-sparkline').replaceChildren(); trendHistory.delete(name); } + else renderSparkline(name, section, stats.rate); } + function updateNetworkSummary(summary) { - const el=document.getElementById('network-summary'); if(!el || !appConfig.show_network_summary || !summary)return; - el.style.display='block'; el.querySelector('.network-total').textContent=Number(summary.total_queries||0).toLocaleString(); el.querySelector('.network-blocked').textContent=Number(summary.blocked_queries||0).toLocaleString(); el.querySelector('.network-percent').textContent=`${Number(summary.percent_blocked||0).toFixed(1)}%`; el.querySelector('.network-cache').textContent=`${Number(summary.cached_queries||0).toLocaleString()} / ${Number(summary.forwarded_queries||0).toLocaleString()}`; - const parts=[]; if(summary.healthy_instances)parts.push(`${summary.healthy_instances} healthy`); if(summary.slow_instances)parts.push(`${summary.slow_instances} slow`); if(summary.offline_instances)parts.push(`${summary.offline_instances} offline`); el.querySelector('.network-health').textContent=parts.join(' ยท ')||'No active Pi-holes'; - } - function updateTimestamp(){ lastUpdateTime=Date.now(); const el=document.getElementById('last-updated'); el.textContent=`Last updated: ${new Date().toLocaleTimeString()}`; el.className='text-xs text-gray-500 dark:text-gray-400'; } - function updateStaleness(){ if(lastUpdateTime&&appConfig&&Date.now()-lastUpdateTime>(appConfig.refresh_interval||5000)*2) document.getElementById('last-updated').className='text-xs text-yellow-600 dark:text-yellow-500'; } - async function refreshStats(){ if(statsFetching)return; statsFetching=true; try{const payload=await fetchData('data?include_summary=true'); for(const [name,raw] of Object.entries(payload.stats||{}))updatePiholeUI(name,raw); updateNetworkSummary(payload.summary); updateTimestamp();}catch(e){console.error('Failed to refresh stats:',e);}finally{statsFetching=false;} } - async function refreshQueries(){ if(!appConfig?.show_queries||queriesFetching)return; queriesFetching=true; try{renderQueries(await fetchData('queries?length=50'));}catch(e){console.error('Failed to refresh queries:',e);}finally{queriesFetching=false;} } - function startTimers(){ if(!appConfig)return; if(!statsTimer)statsTimer=setInterval(refreshStats,appConfig.refresh_interval||5000); if(appConfig.show_queries&&!queriesTimer)queriesTimer=setInterval(refreshQueries,appConfig.queries_refresh_interval||appConfig.refresh_interval||5000); if(!stalenessTimer)stalenessTimer=setInterval(updateStaleness,1000); } - function stopTimers(){ clearInterval(statsTimer);clearInterval(queriesTimer);clearInterval(stalenessTimer);statsTimer=queriesTimer=stalenessTimer=null; } - function renderQueries(allQueries){ const container=document.getElementById('background-queries'); if(!container||queryFeedPaused)return; const newItems=[],pending={}; for(const[name,queries]of Object.entries(allQueries||{})){if(!Array.isArray(queries))continue;const last=lastCursorByPihole[name]??-Infinity;let max=last;for(const q of queries){const id=q.id!=null?Number(q.id):null,time=q.time!=null?Number(q.time):null,cursor=id!==null?id:(time!==null?time:-Infinity);if(cursor>last)newItems.push({...q,piholeName:name,__cursor:cursor});if(cursor>max)max=cursor;}pending[name]=max;} if(!newItems.length)return;Object.assign(lastCursorByPihole,pending);newItems.sort((a,b)=>a.__cursor-b.__cursor);for(const item of newItems){const li=document.createElement('li');li.className=`opacity-90 text-[10px] leading-tight px-1 whitespace-nowrap ${item.blocked?'text-red-600 dark:text-red-500':'text-green-600 dark:text-green-500'}`;li.textContent=`[${item.piholeName}] ${item.domain||''}`;container.appendChild(li);}while(container.children.length>100)container.removeChild(container.firstChild); } - function createCard(p){ const section=document.createElement('section');section.id=`pihole-${p.name}-section`;section.className='bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full';const safeName=escapeHtml(p.name),name=p.link?`${safeName}`:safeName; section.innerHTML=`

${name} (--/sec)

Checking -- ms
Total Queries--
Queries Blocked--
Percent Blocked--%
Cached / Forwarded-- / --
Unique Domains--
Active Clients--
Domains on Lists--
`; return section; } - async function init(){ try{const data=await fetchData('init');appConfig=data.config;const main=document.querySelector('main');main.innerHTML='';for(const p of appConfig.piholes||[])main.appendChild(createCard(p));for(const[name,raw]of Object.entries(data.data||{}))updatePiholeUI(name,raw);updateNetworkSummary(data.summary);if(data.queries)renderQueries(data.queries);updateTimestamp();startTimers();}catch(e){console.error('Failed to initialize dashboard:',e);document.getElementById('last-updated').textContent='Unable to initialize dashboard';} } - document.addEventListener('visibilitychange',()=>{if(document.hidden)stopTimers();else{refreshStats();refreshQueries();startTimers();}});window.addEventListener('offline',stopTimers);window.addEventListener('online',()=>{refreshStats();refreshQueries();startTimers();});const queries=document.getElementById('background-queries');queries?.addEventListener('mouseenter',()=>{queryFeedPaused=true;});queries?.addEventListener('mouseleave',()=>{queryFeedPaused=false;});if('serviceWorker'in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('sw.js').catch(console.error));init(); -}); + if (!appConfig.show_network_summary || !summary) return; + network.hidden = false; + network.querySelector('.network-health').textContent = core.networkStatus(summary); + network.querySelector('.network-total').textContent = number(summary.total_queries); + network.querySelector('.network-blocked').textContent = number(summary.blocked_queries); + network.querySelector('.network-percent').textContent = `${Number(summary.percent_blocked || 0).toFixed(1)}%`; + network.querySelector('.network-cache').textContent = `${number(summary.cached_queries)} / ${number(summary.forwarded_queries)}`; + const partial = summary.partial || summary.contributing_instances < summary.instances; + network.querySelector('.network-partial').hidden = !partial; + network.classList.toggle('is-partial', Boolean(partial)); + } + + function updateTimestamp() { + lastUpdateTime = Date.now(); + timestamp.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; + timestamp.classList.remove('is-stale'); + } + function updateStaleness() { + if (!lastUpdateTime || !appConfig) return; + timestamp.classList.toggle('is-stale', Date.now() - lastUpdateTime > appConfig.refresh_interval * 2); + } + + function renderQueryRows() { + const fragment = document.createDocumentFragment(); + for (const group of queryRows) { + const row = element('li', group.blocked ? 'query-blocked' : 'query-allowed'); + row.textContent = group.label + (group.count > 1 ? ` (x${group.count})` : ''); + row.title = group.label; + fragment.append(row); + } + queryContainer.replaceChildren(fragment); + if (queryPanel.classList.contains('is-open')) queryContainer.scrollTop = queryContainer.scrollHeight; + } + + function flushQueries() { + if (queryFeedPaused || !pendingQueries.length) return; + const groups = core.groupConsecutiveQueries(pendingQueries); + pendingQueries = []; + const merged = core.mergeConsecutiveGroups(queryRows, groups, MAX_ROWS); + queryRows.splice(0, queryRows.length, ...merged); + renderQueryRows(); + } + + function renderQueries(data) { + const events = core.collectNewQueries(queryTracker, data); + if (!events.length) return; + pendingQueries.push(...events); + if (pendingQueries.length > MAX_PENDING) pendingQueries = pendingQueries.slice(-MAX_PENDING); + flushQueries(); + } + + function setQueryPaused(paused) { + queryFeedPaused = paused; + pauseButton.textContent = paused ? 'Resume' : 'Pause'; + pauseButton.setAttribute('aria-pressed', String(paused)); + if (!paused) flushQueries(); + } + + function canApply(generation, signal) { + return generation === foregroundGeneration && visible() && !signal.aborted; + } + + async function refreshStats() { + if (!appConfig || !visible() || statsFetching) return; + statsFetching = true; + const generation = foregroundGeneration; + const controller = new AbortController(); + statsController = controller; + try { + const payload = await fetchData('data?include_summary=true', controller.signal); + if (!canApply(generation, controller.signal)) return; + for (const [name, raw] of Object.entries(payload.stats || {})) updatePiholeUI(name, raw); + updateNetworkSummary(payload.summary); + updateTimestamp(); + } catch (error) { + if (error.name !== 'AbortError') console.error('Failed to refresh stats:', error); + } finally { + statsFetching = false; + if (statsController === controller) statsController = null; + } + } + + async function refreshQueries() { + if (!appConfig?.show_queries || !visible() || queriesFetching) return; + queriesFetching = true; + const generation = foregroundGeneration; + const controller = new AbortController(); + queriesController = controller; + try { + const data = await fetchData('queries?length=50', controller.signal); + if (canApply(generation, controller.signal)) renderQueries(data); + } catch (error) { + if (error.name !== 'AbortError') console.error('Failed to refresh queries:', error); + } finally { + queriesFetching = false; + if (queriesController === controller) queriesController = null; + } + } + + function stopTimers() { + foregroundGeneration++; + clearInterval(statsTimer); clearInterval(queriesTimer); clearInterval(stalenessTimer); + statsTimer = queriesTimer = stalenessTimer = null; + initController?.abort(); statsController?.abort(); queriesController?.abort(); + } + + function startTimers() { + if (!appConfig || !visible() || statsTimer) return; + statsTimer = setInterval(refreshStats, appConfig.refresh_interval); + if (appConfig.show_queries) queriesTimer = setInterval(refreshQueries, appConfig.queries_refresh_interval); + stalenessTimer = setInterval(updateStaleness, 1000); + } + + async function init() { + if (!visible()) return; + const generation = foregroundGeneration; + const controller = new AbortController(); + initController = controller; + try { + const data = await fetchData('init', controller.signal); + if (!canApply(generation, controller.signal)) return; + appConfig = data.config; + main.replaceChildren(); cards.clear(); + for (const [index, pihole] of (appConfig.piholes || []).entries()) main.append(createCard(pihole, index)); + if (!cards.size) main.append(element('p', 'empty-message', 'No Pi-holes are enabled. Check config.json.')); + for (const [name, raw] of Object.entries(data.data || {})) updatePiholeUI(name, raw); + updateNetworkSummary(data.summary); + queryPanel.hidden = !appConfig.show_queries; + if (data.queries) renderQueries(data.queries); + updateTimestamp(); + startTimers(); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Failed to initialize dashboard:', error); + timestamp.textContent = 'Unable to initialize dashboard'; + } + } finally { + if (initController === controller) initController = null; + } + } + + function resumeForeground() { + if (!visible()) return; + if (!appConfig) { if (!initController) init(); return; } + refreshStats(); refreshQueries(); startTimers(); + } + document.addEventListener('visibilitychange', () => { + if (document.hidden) stopTimers(); else resumeForeground(); + }); + window.addEventListener('offline', stopTimers); + window.addEventListener('online', resumeForeground); + window.addEventListener('pagehide', stopTimers); + window.addEventListener('pageshow', resumeForeground); + + queryToggle.addEventListener('click', () => { + const open = queryPanel.classList.toggle('is-open'); + queryToggle.setAttribute('aria-expanded', String(open)); + queryToggle.textContent = open ? 'Hide queries' : 'Show queries'; + if (open) queryContainer.scrollTop = queryContainer.scrollHeight; + }); + pauseButton.addEventListener('click', () => setQueryPaused(!queryFeedPaused)); + queryContainer.addEventListener('mouseenter', () => setQueryPaused(true)); + queryContainer.addEventListener('mouseleave', () => setQueryPaused(false)); + if ('serviceWorker' in navigator) window.addEventListener('load', () => { + navigator.serviceWorker.register('sw.js').catch(error => console.warn('Service worker:', error)); + }); + init(); +}); \ No newline at end of file From 6a85349d645f8ab10d6de057606fab49a89dd0a0 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:10:19 +0530 Subject: [PATCH 25/45] Introduce compact mobile dashboard structure and accessible details controls --- index.html | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 356088d..4c1fbcc 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Pi-hole Dashboard @@ -18,18 +18,19 @@ } - +

Pi-Dash

-

Last updated: Never

+

Last updated: Never

-
-
- +
    + +
    diff --git a/js/app.js b/js/app.js index 152b8dc..c73da18 100644 --- a/js/app.js +++ b/js/app.js @@ -116,10 +116,12 @@ document.addEventListener('DOMContentLoaded', () => { toggle.type = 'button'; toggle.setAttribute('aria-expanded', 'false'); toggle.setAttribute('aria-controls', details.id); + toggle.setAttribute('aria-label', `Show details for ${pihole.name}`); toggle.addEventListener('click', () => { const expanded = section.classList.toggle('is-expanded'); toggle.setAttribute('aria-expanded', String(expanded)); toggle.textContent = expanded ? 'Hide details' : 'Show details'; + toggle.setAttribute('aria-label', `${toggle.textContent} for ${pihole.name}`); }); section.append(toggle); cards.set(pihole.name, section); @@ -128,6 +130,7 @@ document.addEventListener('DOMContentLoaded', () => { function renderSparkline(name, section, rate) { const target = section.querySelector('.pihole-sparkline'); + target.hidden = !appConfig.show_trends; if (!appConfig.show_trends) { target.replaceChildren(); return; } const history = trendHistory.get(name) || []; history.push(rate); @@ -206,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => { fragment.append(row); } queryContainer.replaceChildren(fragment); - if (queryPanel.classList.contains('is-open')) queryContainer.scrollTop = queryContainer.scrollHeight; + queryContainer.scrollTop = queryContainer.scrollHeight; } function isQueryPaused() { return manualPause || hoverPause; } @@ -249,7 +252,7 @@ document.addEventListener('DOMContentLoaded', () => { updateNetworkSummary(payload.summary); updateTimestamp(); } catch (error) { - if (error.name !== 'AbortError') console.error('Failed to refresh stats:', error); + if (canApply(generation, controller.signal) && error.name !== 'AbortError') console.error('Failed to refresh stats:', error); } finally { if (statsController === controller) statsController = null; } @@ -264,7 +267,7 @@ document.addEventListener('DOMContentLoaded', () => { const data = await fetchData('queries?length=50', controller.signal); if (canApply(generation, controller.signal)) renderQueries(data); } catch (error) { - if (error.name !== 'AbortError') console.error('Failed to refresh queries:', error); + if (canApply(generation, controller.signal) && error.name !== 'AbortError') console.error('Failed to refresh queries:', error); } finally { if (queriesController === controller) queriesController = null; } @@ -304,7 +307,7 @@ document.addEventListener('DOMContentLoaded', () => { updateTimestamp(); startTimers(); } catch (error) { - if (error.name !== 'AbortError') { + if (canApply(generation, controller.signal) && error.name !== 'AbortError') { console.error('Failed to initialize dashboard:', error); timestamp.textContent = 'Unable to initialize dashboard'; } diff --git a/package.json b/package.json index 1ab0b4f..479d3c8 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,14 @@ "version": "1.0.0", "private": true, "devDependencies": { - "tailwindcss": "^3.3.0", - "jsdom": "^26.1.0" + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "tailwindcss": "^3.3.0" }, "scripts": { "build:css": "tailwindcss -c tailwind.config.js -i ./src/tailwind.css -o ./css/style.css --minify", - "test:js": "node --test tests/*.test.cjs" + "test:js": "node --test tests/*.test.cjs", + "dev": "node tests/browser/server.cjs", + "test:browser": "node tests/browser/run.cjs" } } diff --git a/proxy.py b/proxy.py index 838414c..e463557 100644 --- a/proxy.py +++ b/proxy.py @@ -97,23 +97,21 @@ def headers(sid): def authenticate_and_get_sid(pihole): address = pihole['address'].rstrip('/') - try: - response = requests.post( - address + '/api/auth', - json={'password': resolve_secret(pihole.get('password', ''))}, - timeout=DEFAULT_REQUEST_TIMEOUT, - verify=get_verify_setting(pihole), - ) - if response.status_code == 200: - data = response.json() - sid = data.get('session', {}).get('sid') - if sid: - return sid - if data.get('session', {}).get('message') == 'no password set': - return NO_PASSWORD - return None - except requests.exceptions.RequestException: - return None + response = requests.post( + address + '/api/auth', + json={'password': resolve_secret(pihole.get('password', ''))}, + timeout=DEFAULT_REQUEST_TIMEOUT, + verify=get_verify_setting(pihole), + ) + if response.status_code == 200: + data = response.json() + sid = data.get('session', {}).get('sid') + if sid: + return sid + if data.get('session', {}).get('message') == 'no password set': + return NO_PASSWORD + response.raise_for_status() + return None def get_sid(pihole, force=False): @@ -256,7 +254,9 @@ def fetch_one(pihole): except RuntimeError as exc: return name, {'error': str(exc), '_pi_dash': {'health': 'auth_error'}} except (requests.exceptions.RequestException, ValueError) as exc: - return name, {'error': str(exc), '_pi_dash': {'health': 'unreachable'}} + status = getattr(getattr(exc, 'response', None), 'status_code', None) + health = 'auth_error' if status in (401, 403) else 'unreachable' + return name, {'error': str(exc), '_pi_dash': {'health': health}} def _stats_uncached(): diff --git a/sw.js b/sw.js index f8c8921..ad97b3b 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'pi-dashboard-cache-v2-mobile'; +const CACHE_NAME = 'pi-dashboard-cache-v3-responsive'; const APP_SHELL = [ '{{CACHE_URL}}', '{{CACHE_URL}}css/style.css', diff --git a/tailwind.config.js b/tailwind.config.js index 9d298ee..44edfd5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,5 @@ module.exports = { + darkMode: 'class', content: [ "./index.html", "./js/**/*.js" diff --git a/tests/browser/checks.js b/tests/browser/checks.js new file mode 100644 index 0000000..4d5bb54 --- /dev/null +++ b/tests/browser/checks.js @@ -0,0 +1,149 @@ +/* Run in a real browser: jsdom cannot validate these geometry assertions. */ +const result = document.querySelector('#result'); +const settle = () => new Promise(resolve => setTimeout(resolve, 35)); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const shown = e => e.getClientRects().length > 0; +const bounds = e => e.getBoundingClientRect(); +const overlap = (a, b) => a.left < b.right - 1 && a.right > b.left + 1 && a.top < b.bottom - 1 && a.bottom > b.top + 1; +function geometry(d, stage) { + const width = d.documentElement.clientWidth; + assert(d.documentElement.scrollWidth <= width, `${stage}: page overflows horizontally`); + assert(bounds(d.querySelector('header')).top >= -1, `${stage}: header is clipped at top`); + assert(bounds(d.querySelector('#network-summary')).top >= -1, `${stage}: summary is clipped at top`); + const cards = [...d.querySelectorAll('.instance-card')]; + const panel = bounds(d.querySelector('#query-panel')); + for (const card of cards) { + const r = bounds(card); + assert(r.left >= -1 && r.right <= width + 1, `${stage}: card exceeds viewport`); + assert(!overlap(r, panel), `${stage}: query feed overlaps card`); + for (const e of card.querySelectorAll('.metric-label,.metric-value,.pihole-rate,.pihole-health,.instance-toggle')) { + if (!shown(e)) continue; + const rects = [...e.getClientRects()]; + assert(rects.every(b => b.left >= r.left - 1 && b.right <= r.right + 1), `${stage}: ${e.className} exceeds card`); + assert(e.scrollWidth <= e.clientWidth + 1 || e.tagName === 'SPAN', `${stage}: text is clipped`); + } + for (const row of card.querySelectorAll('.metric-row')) { + if (shown(row)) assert(!overlap(bounds(row.firstElementChild), bounds(row.lastElementChild)), `${stage}: metric label overlaps value`); + } + } + for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) assert(!overlap(bounds(cards[i]), bounds(cards[j])), `${stage}: cards overlap`); + for (const e of d.querySelectorAll('.network-metrics > div')) assert(e.scrollWidth <= e.clientWidth + 1, `${stage}: network metric overflow`); + const list = d.querySelector('#background-queries'); + if (shown(list)) assert(list.scrollWidth <= list.clientWidth + 1, `${stage}: query text overflow`); +} +async function loadViewport(width, height, scenario, dark) { + frame.width = width; + frame.height = height; + await new Promise(resolve => { + frame.onload = resolve; + frame.src = `/fixture/?scenario=${scenario}&dark=${dark}&run=${Date.now()}`; + }); + await settle(); + const d = frame.contentDocument; + assert(d.querySelector('.instance-card,.empty-message'), 'dashboard did not initialize'); + return d; +} +async function checkLayout(width, height, scenario, dark) { + const d = await loadViewport(width, height, scenario, dark); + const compact = width <= 767 || width <= 1024 && height <= 500; + geometry(d, 'default'); + assert(frame.contentWindow.getComputedStyle(d.body).backgroundColor === (dark ? 'rgb(31, 41, 55)' : 'rgb(243, 244, 246)'), 'theme does not match saved preference'); + const toggles = [...d.querySelectorAll('.instance-toggle')]; + for (const toggle of toggles) { + assert(shown(toggle) === compact, 'wrong compact/desktop mode'); + assert(toggle.getAttribute('aria-expanded') === 'false', 'initial expanded state'); + const details = d.getElementById(toggle.getAttribute('aria-controls')); + assert(shown(details) === !compact, 'wrong metric visibility'); + assert(details.children.length === 7, 'missing desktop metrics'); + assert(toggle.getAttribute('aria-label').includes('details for '), 'missing per-instance accessible name'); + if (compact) { + toggle.click(); + assert(shown(details) && toggle.getAttribute('aria-expanded') === 'true', 'details did not expand'); + } + } + geometry(d, 'expanded'); + const queryToggle = d.querySelector('#query-toggle'); + if (compact) queryToggle.click(); + assert(shown(d.querySelector('#background-queries')), 'query feed did not open'); + geometry(d, 'queries open'); + if (scenario !== 'zero') assert(d.querySelector('#background-queries').textContent.includes('(x3)'), 'consecutive grouping missing'); + if (scenario === 'states') { + for (const state of ['Blocking ON', 'Blocking OFF', 'Offline', 'Auth failed', 'blocking unknown']) assert(d.querySelector('main').textContent.includes(state), `missing ${state}`); + assert(shown(d.querySelector('.network-partial')), 'missing partial summary'); + } + if (scenario === 'zero') assert(d.querySelector('.empty-message') && !toggles.length, 'zero instances failed'); + if (compact) { + for (const toggle of toggles) toggle.click(); + queryToggle.click(); + geometry(d, 'collapsed again'); + } + // Resize the same live document across the breakpoint with details expanded. + if (toggles.length && compact) { + toggles[0].click(); + frame.width = 1280; frame.height = 900; + await settle(); + assert([...d.querySelectorAll('.instance-details')].every(shown), 'desktop lost full metrics after resize'); + frame.width = width; frame.height = height; + await settle(); + assert(shown(d.querySelector('.instance-details')), 'expanded state lost after resize'); + toggles[0].click(); + } +} +async function checkLifecycle() { + const d = await loadViewport(375, 667, 'normal', false); + const w = frame.contentWindow, f = w.fixture; + const expectedTimers = () => assert(JSON.stringify([...f.timers.values()].map(t => t.ms).sort((a,b)=>a-b)) === '[1000,2000,3000]', 'duplicate or missing polling timers'); + expectedTimers(); + // Keep requests pending even after abort, like a late response/body or ignored abort. + const oldStats = f.defer('data'), oldQueries = f.defer('queries'); + const statsTick = f.tick(2000), queryTick = f.tick(3000); + await settle(); + f.hide(true); + assert(f.timers.size === 0, 'hidden page retains timers'); + assert(f.calls.slice(-2).every(c => c.signal.aborted), 'hidden page does not abort both requests'); + const hiddenCalls = f.calls.length; + await f.tick(2000); await f.tick(3000); + assert(f.calls.length === hiddenCalls, 'hidden page polls'); + f.release('data'); f.release('queries'); + f.data.Primary.queries.total = 999; + f.queries.Primary = [{ id: 100, time: 100, domain: 'fresh.test', blocked: false }]; + f.hide(false); await settle(); expectedTimers(); + const value = () => d.querySelector('.instance-details [data-value="total"]').textContent; + assert(value() === '999', 'resume did not refresh stats'); + oldStats.resolve({ ok: true, json: async () => ({ stats: { Primary: { queries: { total: 111 } } } }) }); + oldQueries.resolve({ ok: true, json: async () => ({ Primary: [{ id: 999, time: 999, domain: 'stale.test' }] }) }); + await Promise.all([statsTick, queryTick]); + assert(value() === '999' && !d.querySelector('#background-queries').textContent.includes('stale.test'), 'late response overwrote foreground state'); + for (let i=0; i<3; i++) { + f.online(false); assert(f.timers.size === 0, 'offline page retains timers'); + const count = f.calls.length; await f.tick(2000); await f.tick(3000); + assert(f.calls.length === count, 'offline page polls'); + f.hide(true); f.online(true); assert(f.timers.size === 0, 'online event resumes hidden page'); + f.hide(false); await settle(); expectedTimers(); + } + f.hide(true); + const held = f.defer('data'); + f.hide(false); await settle(); + const count = f.calls.length; + await f.tick(2000); + assert(f.calls.length === count, 'overlapping stats request'); + f.release('data'); held.resolve({ ok: true, json: async () => ({ stats: f.data, summary: f.summary }) }); + await settle(); + w.dispatchEvent(new Event('pagehide')); assert(f.timers.size === 0, 'pagehide retains timers'); + w.dispatchEvent(new Event('pageshow')); await settle(); expectedTimers(); +} +document.querySelector('#run').addEventListener('click', async () => { + const button = document.querySelector('#run'); button.disabled = true; + result.dataset.status = 'running'; result.textContent = 'Running rendered checks...'; + const sizes = [[320,568],[568,320],[375,667],[667,375],[430,932],[932,430],[768,1024],[1024,768],[768,375],[800,600],[1024,500],[1280,720],[1440,900],[1920,1080]]; + const failures = []; let passed = 0; + for (const [width,height] of sizes) for (const scenario of ['normal','long','many','zero','states']) for (const dark of [false,true]) { + const name = `${width}ร—${height} ${scenario} ${dark ? 'dark' : 'light'}`; + try { await checkLayout(width,height,scenario,dark); passed++; } + catch(error) { failures.push(`${name}: ${error.message}`); } + result.textContent = `${passed} passed; ${failures.length} failed. Checking ${name}\n${failures.join('\n')}`; + } + try { await checkLifecycle(); passed++; } catch(error) { failures.push(`Polling lifecycle: ${error.message}`); } + result.textContent = `${passed} passed; ${failures.length} failed.\n${failures.join('\n')}`; + result.dataset.status = failures.length ? 'failed' : 'passed'; button.disabled = false; +}); diff --git a/tests/browser/index.html b/tests/browser/index.html new file mode 100644 index 0000000..dc4ec9c --- /dev/null +++ b/tests/browser/index.html @@ -0,0 +1,16 @@ + +Pi-Dash rendered regressions + +

    Pi-Dash rendered regressions

    +
    +

    Ready
    + diff --git a/tests/browser/mock.js b/tests/browser/mock.js new file mode 100644 index 0000000..50046d2 --- /dev/null +++ b/tests/browser/mock.js @@ -0,0 +1,56 @@ +/* Synthetic data only. Loaded before the unmodified production scripts. */ +(() => { + const params = new URLSearchParams(location.search); + const scenario = params.get('scenario') || 'normal'; + localStorage.theme = params.get('dark') === 'true' ? 'dark' : 'light'; + const count = scenario === 'zero' ? 0 : scenario === 'many' ? 6 : scenario === 'states' ? 5 : 2; + const names = Array.from({ length: count }, (_, i) => scenario === 'long' ? `Pi-hole-${i}-with-a-very-long-unbroken-instance-name` : ['Primary', 'Secondary', 'Unreachable', 'Authentication', 'Unknown', 'Spare'][i]); + const sample = () => ({ queries: { total: scenario === 'long' ? 987654321098765 : 89151, blocked: 23808, percent_blocked: 26.7, frequency: scenario === 'long' ? 123456789.1 : 5.6, cached: scenario === 'long' ? 123456789012345 : 45402, forwarded: scenario === 'long' ? 987654321098765 : 42939, unique_domains: 4281 }, clients: { active: 18 }, gravity: { domains_being_blocked: scenario === 'long' ? 987654321098765 : 1284390 }, _pi_dash: { blocking: true } }); + const data = Object.fromEntries(names.map(name => [name, sample()])); + if (scenario === 'states') { + data.Secondary._pi_dash.blocking = false; + data.Unreachable = { error: 'unreachable', _pi_dash: { health: 'unreachable' } }; + data.Authentication = { error: 'authentication failed', _pi_dash: { health: 'auth_error' } }; + data.Unknown._pi_dash.blocking = null; + } + const queries = count ? { [names[0]]: Array.from({ length: 36 }, (_, i) => ({ id: i + 1, time: i + 1, domain: i > 32 ? 'repeat.example.org' : `query-${i}.example.org`, blocked: i < 10 })) } : {}; + const cfg = { refresh_interval: 2000, queries_refresh_interval: 3000, show_queries: true, show_network_summary: true, show_trends: true, piholes: names.map(name => ({ name, link: false, enabled: true })) }; + const summary = { total_queries: count * sample().queries.total, blocked_queries: count * 23808, percent_blocked: 26.7, cached_queries: count * sample().queries.cached, forwarded_queries: count * sample().queries.forwarded, instances: count, contributing_instances: scenario === 'states' ? 3 : count, offline_instances: scenario === 'states' ? 2 : 0, blocking_disabled_instances: scenario === 'states' ? 1 : 0, blocking_unknown_instances: scenario === 'states' ? 1 : 0, partial: scenario === 'states' }; + const native = params.get('native') === 'true'; + const realSetInterval = window.setInterval.bind(window), realClearInterval = window.clearInterval.bind(window); + let hidden = false, online = true, nextTimer = 0; + const timers = new Map(), calls = [], deferred = new Map(); + if (!native) { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }); + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); + } + Object.defineProperty(navigator, 'serviceWorker', { value: { register: async () => ({}) } }); + window.setInterval = (fn, ms) => { const id = native ? realSetInterval(fn, ms) : ++nextTimer; timers.set(id, { fn, ms }); return id; }; + window.clearInterval = id => { if (native) realClearInterval(id); timers.delete(id); }; + window.fetch = async (url, options) => { + const type = url.split('?')[0]; + calls.push({ type, signal: options.signal }); + if (deferred.has(type)) return new Promise((resolve, reject) => Object.assign(deferred.get(type), { resolve, reject })); + return { ok: true, json: async () => type === 'init' ? { config: cfg, data, summary, queries } : type === 'data' ? { stats: data, summary } : queries }; + }; + if (native) document.addEventListener('DOMContentLoaded', () => { + const output = document.createElement('output'); + output.id = 'native-polling'; + output.style.cssText = 'display:block;overflow-wrap:anywhere;font:12px monospace'; + document.body.append(output); + const transitions = []; + const report = () => { output.textContent = JSON.stringify({ hidden: document.hidden, online: navigator.onLine, calls: calls.length, timers: timers.size, transitions }); }; + document.addEventListener('visibilitychange', () => { + setTimeout(() => { transitions.push({ hidden: document.hidden, calls: calls.length, timers: timers.size }); report(); }, 0); + }); + realSetInterval(report, 250); + }); + window.fixture = { + timers, calls, data, queries, summary, + hide(value) { hidden = value; document.dispatchEvent(new Event('visibilitychange')); }, + online(value) { online = value; window.dispatchEvent(new Event(value ? 'online' : 'offline')); }, + defer(type) { const request = {}; deferred.set(type, request); return request; }, + release(type) { deferred.delete(type); }, + async tick(ms) { await Promise.all([...timers.values()].filter(t => t.ms === ms).map(t => t.fn())); }, + }; +})(); diff --git a/tests/browser/run.cjs b/tests/browser/run.cjs new file mode 100644 index 0000000..40dfe40 --- /dev/null +++ b/tests/browser/run.cjs @@ -0,0 +1,47 @@ +/* CI/local automation for the same visible, inspectable browser fixture. */ +const { chromium } = require('playwright'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +(async () => { + const server = spawn(process.execPath, [path.join(__dirname, 'server.cjs'), '--port', '5011'], { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }); + let browser; + try { + await new Promise((resolve, reject) => { + server.stdout.once('data', resolve); + server.once('exit', code => reject(new Error(`Fixture server exited: ${code}`))); + server.once('error', reject); + }); + browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await page.goto('http://localhost:5011/tests/browser/'); + await page.getByRole('button', { name: 'Run all rendered checks' }).click(); + await page.locator('#result[data-status="passed"], #result[data-status="failed"]').waitFor({ timeout: 120000 }); + console.log(await page.locator('#result').innerText()); + fs.mkdirSync(path.join(root, 'test-results'), { recursive: true }); + await page.screenshot({ path: path.join(root, 'test-results/rendered-results.png'), fullPage: true }); + if (await page.locator('#result').getAttribute('data-status') !== 'passed') process.exitCode = 1; + // Actual navigator offline/online events, with real browser timers. + await page.goto('http://localhost:5011/fixture/?native=true'); + await page.waitForFunction(() => document.querySelector('#native-polling')?.textContent.includes('"timers":3')); + await page.context().setOffline(true); + await page.waitForFunction(() => document.querySelector('#native-polling')?.textContent.includes('"timers":0')); + const offlineCalls = JSON.parse(await page.locator('#native-polling').innerText()).calls; + await page.waitForTimeout(3200); + if (JSON.parse(await page.locator('#native-polling').innerText()).calls !== offlineCalls) throw new Error('Browser offline state still polls'); + await page.context().setOffline(false); + await page.waitForFunction(() => document.querySelector('#native-polling')?.textContent.includes('"timers":3')); + console.log('Real browser offline/online polling: passed'); + // Keep reviewable screenshots of production markup at actual top-level viewport sizes. + for (const [width, height] of [[320,568],[375,667],[430,932],[768,375],[768,1024],[1440,900]]) { + await page.setViewportSize({ width, height }); + await page.goto('http://localhost:5011/fixture/'); + await page.locator('.instance-card').first().waitFor(); + await page.screenshot({ path: path.join(root, `test-results/${width}x${height}.png`), fullPage: true }); + } + } finally { + if (browser) await browser.close(); + server.kill(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/browser/server.cjs b/tests/browser/server.cjs new file mode 100644 index 0000000..e0854fd --- /dev/null +++ b/tests/browser/server.cjs @@ -0,0 +1,26 @@ +/* Local-only fixture: real production assets, synthetic Pi-hole API responses. */ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +const arg = process.argv.indexOf('--port'); +const port = arg < 0 ? 5011 : Number(process.argv[arg + 1]); +http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + res.setHeader('Cache-Control', 'no-store'); + if (pathname.startsWith('/fixture/')) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(fs.readFileSync(path.join(root, 'index.html'), 'utf8').replace('{{ICON_URL}}', '/favicon.ico').replace('', '')); + return; + } + if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } + const relative = pathname === '/' ? '/tests/browser/index.html' : pathname.endsWith('/') ? pathname + 'index.html' : pathname; + const file = path.resolve(root, '.' + relative); + // Only public assets and this synthetic test harness may be served. + if (!file.startsWith(root + path.sep) || !/^\/(css|js|tests\/browser)\//.test(relative)) { res.writeHead(404); res.end(); return; } + fs.readFile(file, (error, data) => { + if (error) { res.writeHead(404); res.end(); return; } + res.setHeader('Content-Type', { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[path.extname(file)] || 'text/plain'); + res.end(data); + }); +}).listen(port, '0.0.0.0', () => console.log(`Rendered regression suite: http://localhost:${port}/tests/browser/`)); diff --git a/tests/lifecycle.test.cjs b/tests/lifecycle.test.cjs index f1195de..f77025e 100644 --- a/tests/lifecycle.test.cjs +++ b/tests/lifecycle.test.cjs @@ -82,3 +82,21 @@ test('a stale in-flight stats response cannot overwrite a newer foreground snaps assert.equal(app.w.document.querySelector('.instance-details [data-value="total"]').textContent, '3'); } finally { app.close(); } }); + +test('a late initialization error cannot overwrite successful recovery', async () => { + let rejectOld; + let requests = 0; + const app = setup(() => { + if (++requests === 1) return new Promise((_, reject) => { rejectOld = reject; }); + return Promise.resolve(response({ config, data: {}, summary: { instances: 0 } })); + }); + try { + await app.settle(); + app.hide(true); app.hide(false); await app.settle(); + const current = app.w.document.getElementById('last-updated').textContent; + rejectOld(new Error('late network failure')); + await app.settle(); + assert.equal(app.w.document.getElementById('last-updated').textContent, current); + assert.equal(app.timers.size, 2); + } finally { app.close(); } +}); diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 5984033..e04fa9e 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -56,7 +56,8 @@ def test_example_is_valid_json_and_comments_are_ignored(self): with open(os.path.join(proxy.APP_ROOT, 'config-example.json'), encoding='utf-8') as handle: example = json.load(handle) self.assertIn('_comment', example) - self.assertIn('refresh_interval', example['_comment']) + self.assertEqual(set(example) - {'_comment'}, set(example['_comment']) - {'about'}) + self.assertEqual(set(example['piholes'][0]) - {'_comment'}, set(example['piholes'][0]['_comment'])) proxy.config = example filtered = proxy.filtered_config() self.assertNotIn('_comment', filtered) @@ -100,6 +101,20 @@ def test_unknown_blocking_is_not_reported_healthy(self): self.assertEqual(result['blocking_unknown_instances'], 1) self.assertFalse(result['partial']) + def test_unreachable_during_initial_auth_is_not_bad_credentials(self): + with patch.object(proxy.requests, 'post', side_effect=proxy.requests.exceptions.ConnectionError('connection refused')): + data = proxy.fetch_one({'name': 'Unreachable', 'address': 'http://localhost'})[1] + self.assertEqual(data['_pi_dash']['health'], 'unreachable') + + def test_http_auth_errors_remain_auth_errors(self): + for status in (401, 403): + response = proxy.requests.Response() + response.status_code = status + error = proxy.requests.exceptions.HTTPError(response=response) + with patch.object(proxy, 'pihole_get', side_effect=error): + data = proxy.fetch_one({'name': 'Auth', 'address': 'http://localhost'})[1] + self.assertEqual(data['_pi_dash']['health'], 'auth_error') + def test_fetch_one_has_no_latency_metric(self): class Response: def json(self): From 97af1e40ca135c1914389dd54e61234598225976 Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:56:10 +0530 Subject: [PATCH 40/45] Restore original desktop background queries and dashboard width --- README.md | 6 +++--- config-example.json | 2 +- css/dashboard.css | 25 ++++++++++--------------- index.html | 20 ++++++++++---------- sw.js | 2 +- tests/browser/checks.js | 16 ++++++++++++++-- 6 files changed, 39 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2349c4d..31584ff 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ Pi-Dash is a lightweight dashboard for monitoring one or more Pi-hole instances. ## Mobile and desktop layout -Desktop keeps the full metric cards, query rates and sparklines. On narrow screens, Pi-Dash uses smaller spacing, a compact Network Summary, and a condensed card for each Pi-hole. The condensed card shows its name, query rate, health/blocking state, total queries, blocked queries and blocked percentage. Tap **Show details** to reveal all seven metrics shown on desktop; tap **Hide details** to collapse it again. +Desktop keeps the original 576-pixel-wide dashboard, full metric cards, query rates, sparklines and viewport-wide ambient query feed behind the cards. On narrow screens, Pi-Dash uses smaller spacing, a compact Network Summary, and a condensed card for each Pi-hole. The condensed card shows its name, query rate, health/blocking state, total queries, blocked queries and blocked percentage. Tap **Show details** to reveal all seven metrics shown on desktop; tap **Hide details** to collapse it again. Compact mode applies below 768 CSS pixels wide, and also at up to 1024 pixels wide when the viewport is 500 pixels high or less (including phone landscape and short resized windows). Below 640 pixels, cards use one column; wider layouts use two. Each details button has an instance-specific accessible name, supports keyboard activation, and preserves its expanded state when resizing. -The query feed always occupies its own space below the cards, so it cannot cover them or become a narrow strip behind them. It is collapsible in compact mode and open on desktop, with a bounded scrolling area and a Pause control. Long values and names wrap instead of overflowing. Short desktop dashboards are centered safely; taller dashboards scroll from the top without clipping the heading or Network Summary. Full details and larger instance lists can require vertical scrolling. +In compact mode, the query feed occupies its own space below the cards in a collapsible panel with a bounded scrolling area and a Pause control. Desktop retains the original fixed background feed, bottom alignment and fade; it stays behind the opaque cards and pauses on hover. Long values and names wrap instead of overflowing. Short desktop dashboards are centered safely; taller dashboards scroll from the top without clipping the heading or Network Summary. Full details and larger instance lists can require vertical scrolling. ### Why the latency number was removed @@ -228,7 +228,7 @@ npx playwright install chromium npm run test:browser ``` -The rendered fixture loads the production HTML, CSS and JavaScript with synthetic API responses. It checks 320, 375, 430 and 768 pixel widths, phone landscape, intermediate desktop sizes and desktops up to 1920 pixels, in both themes. It covers compact and expanded cards, long values/names, six Pi-holes, zero Pi-holes, blocking/offline/auth states, feed grouping, card/feed overlap, page overflow, resizing and deterministic visibility/offline races. CI runs these checks and retains screenshots in the `rendered-dashboard` artifact. Playwright is a development-only dependency; Flask and vanilla JavaScript remain the application runtime. +The rendered fixture loads the production HTML, CSS and JavaScript with synthetic API responses. It checks 320, 375, 430 and 768 pixel widths, phone landscape, intermediate desktop sizes and desktops up to 1920 pixels, in both themes. It covers compact and expanded cards, long values/names, six Pi-holes, zero Pi-holes, blocking/offline/auth states, feed grouping, original desktop width and background stacking, compact card/feed separation, page overflow, resizing and deterministic visibility/offline races. CI runs these checks and retains screenshots in the `rendered-dashboard` artifact. Playwright is a development-only dependency; Flask and vanilla JavaScript remain the application runtime. For manual visual and keyboard inspection with the same synthetic data: diff --git a/config-example.json b/config-example.json index f904426..0e81c21 100644 --- a/config-example.json +++ b/config-example.json @@ -5,7 +5,7 @@ "refresh_interval": "Default: 5000 milliseconds. Refreshes the main statistics. The example recommends 2000 ms; existing configurations retain their configured value.", "queries_refresh_interval": "Default: the effective refresh_interval. Separately controls the optional live-query feed. The example recommends 3000 ms. Omitting this key preserves the old query cadence.", "cache_ttl": "Default: min(1000, max(100, min(refresh_interval, queries_refresh_interval) / 2)) milliseconds, rounded down. The example uses 1000 ms. Set 0 to disable the shared in-process cache. An explicit cache TTL may delay fresh upstream data; keep it below your fastest polling interval.", - "show_queries": "Default: false. Enables the optional DNS query feed. Queries are requested only while the browser page is visible and online; hidden/offline pages stop polling. The feed stays below the cards: open on desktop, collapsible in compact mobile/landscape layouts.", + "show_queries": "Default: false. Enables the optional DNS query feed. Queries are requested only while the browser page is visible and online; hidden/offline pages stop polling. Desktop uses the original ambient background feed; compact mobile/landscape layouts use a collapsible panel below the cards.", "show_network_summary": "Default: true. Shows additive DNS totals across reporting Pi-holes. If an instance is unavailable, totals are marked partial. Active clients and unique domains are not summed because they can overlap.", "show_trends": "Default: true. Shows a short query-rate sparkline kept only in browser memory. It does not create a database or store historical DNS activity.", "piholes": "Default: empty list. Add one or more Pi-hole instances below. Names must be unique. Every enabled instance needs a name and address." diff --git a/css/dashboard.css b/css/dashboard.css index c434ccd..2fef5f7 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -2,7 +2,7 @@ body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, html { min-height: 100%; } /* Auto margins center short dashboards but become zero when content needs scrolling. */ body { min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; padding: 1rem; } -#main-wrapper { flex: none; max-width: 48rem; margin-block: auto; } +#main-wrapper { flex: none; max-width: 36rem; margin-block: auto; } html, body { overscroll-behavior-y: contain; } [hidden] { display: none !important; } .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; } @@ -41,23 +41,18 @@ html, body { overscroll-behavior-y: contain; } .dark .pihole-sparkline { color: #2dd4bf; } .pihole-sparkline svg { width: 100%; height: 100%; } .instance-details { display: flex; flex-direction: column; gap: .375rem; font-size: .875rem; } -.metric-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: baseline; gap: .5rem; min-width: 0; } +.metric-row { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; min-width: 0; } .metric-label { color: #6b7280; overflow-wrap: anywhere; } .dark .metric-label { color: #9ca3af; } .metric-value { min-width: 0; font-weight: 500; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .mobile-instance-summary, .instance-toggle { display: none; } .instance-toggle:focus-visible, .query-actions button:focus-visible { outline: 2px solid #0d9488; outline-offset: 3px; } .empty-message { grid-column: 1 / -1; text-align: center; color: #6b7280; font-size: .875rem; padding: 1rem; } -/* The feed occupies its own space after the cards at every viewport size. */ -.query-panel { position: relative; margin-top: 1rem; min-width: 0; } -.query-panel-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: .5rem; } -.query-panel-title { font-size: .75rem; font-weight: 600; } -.query-actions { display: flex; gap: .75rem; } -.query-actions button { min-height: 2.75rem; color: #0f766e; font-size: .75rem; } -.dark .query-actions button { color: #5eead4; } -#query-toggle { display: none; } -#background-queries { display: block; max-height: 25vh; overflow-y: auto; list-style: none; margin: 0; padding: .25rem 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: .6875rem; line-height: 1.45; } -#background-queries li { overflow-wrap: anywhere; } +/* The original ambient query feed remains behind the desktop dashboard. */ +.query-panel { position: fixed; inset: auto 0 0; height: 60vh; max-height: 60vh; z-index: 0; pointer-events: none; } +.query-panel-header { display: none; } +#background-queries { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; list-style: none; margin: 0; padding: 4px 6px 8px; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 10px; line-height: 1.25; pointer-events: auto; -webkit-mask-image: linear-gradient(to top, #000 0%, #000 50%, #0008 80%, transparent 100%); mask-image: linear-gradient(to top, #000 0%, #000 50%, #0008 80%, transparent 100%); } +#background-queries li { text-shadow: 0 1px 2px rgb(0 0 0 / .15); letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .query-allowed { color: #16a34a; } .query-blocked { color: #dc2626; } .dark .query-allowed { color: #4ade80; } @@ -90,17 +85,17 @@ html, body { overscroll-behavior-y: contain; } .instance-card.is-expanded .instance-details { display: flex; } .query-panel { position: relative; inset: auto; height: auto; max-height: none; margin-top: .75rem; border: 1px solid #e5e7eb; border-radius: .5rem; background: white; pointer-events: auto; overflow: hidden; } .dark .query-panel { border-color: #374151; background: #111827; } - .query-panel-header { padding: .25rem .75rem; } + .query-panel-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: .5rem; padding: .25rem .75rem; } #query-toggle { display: inline; } .query-panel-title { font-size: .75rem; font-weight: 600; } .query-actions { display: flex; gap: .75rem; } - .query-actions button { color: #0f766e; font-size: .75rem; } + .query-actions button { min-height: 2.75rem; color: #0f766e; font-size: .75rem; } .dark .query-actions button { color: #5eead4; } #query-pause { display: none; } .query-panel.is-open #query-pause { display: inline; } #background-queries { display: none; position: relative; inset: auto; height: auto; max-height: 30vh; overflow-y: auto; overflow-x: hidden; padding: .25rem .75rem .75rem; -webkit-mask-image: none; mask-image: none; pointer-events: auto; font-size: .6875rem; line-height: 1.45; } .query-panel.is-open #background-queries { display: block; } - #background-queries li { padding: .125rem 0; text-shadow: none; } + #background-queries li { padding: .125rem 0; text-shadow: none; white-space: normal; overflow-wrap: anywhere; } } @media (max-width: 639px) { #main-wrapper > main { grid-template-columns: minmax(0, 1fr); } diff --git a/index.html b/index.html index 93909fe..e92c724 100644 --- a/index.html +++ b/index.html @@ -39,17 +39,17 @@

    Network Summa
    - + diff --git a/sw.js b/sw.js index ad97b3b..5faef4d 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'pi-dashboard-cache-v3-responsive'; +const CACHE_NAME = 'pi-dashboard-cache-v4-desktop-background'; const APP_SHELL = [ '{{CACHE_URL}}', '{{CACHE_URL}}css/style.css', diff --git a/tests/browser/checks.js b/tests/browser/checks.js index 4d5bb54..838580a 100644 --- a/tests/browser/checks.js +++ b/tests/browser/checks.js @@ -11,11 +11,22 @@ function geometry(d, stage) { assert(bounds(d.querySelector('header')).top >= -1, `${stage}: header is clipped at top`); assert(bounds(d.querySelector('#network-summary')).top >= -1, `${stage}: summary is clipped at top`); const cards = [...d.querySelectorAll('.instance-card')]; - const panel = bounds(d.querySelector('#query-panel')); + const panelElement = d.querySelector('#query-panel'); + const panel = bounds(panelElement); + const ambient = d.defaultView.getComputedStyle(panelElement).position === 'fixed'; + if (ambient) { + assert(Math.abs(bounds(d.querySelector('#main-wrapper')).width - 576) < 1, `${stage}: original desktop width changed`); + assert(Math.abs(panel.left) < 1 && Math.abs(panel.width - width) < 1, `${stage}: background feed is not viewport-wide`); + assert(!shown(d.querySelector('.query-panel-header')), `${stage}: desktop feed became a foreground panel`); + } for (const card of cards) { const r = bounds(card); assert(r.left >= -1 && r.right <= width + 1, `${stage}: card exceeds viewport`); - assert(!overlap(r, panel), `${stage}: query feed overlaps card`); + if (!ambient) assert(!overlap(r, panel), `${stage}: query feed overlaps compact card`); + else if (r.top < d.documentElement.clientHeight) { + const hit = d.elementFromPoint(r.left + 5, Math.max(0, r.top) + 5); + assert(hit && card.contains(hit), `${stage}: background queries obscure desktop card`); + } for (const e of card.querySelectorAll('.metric-label,.metric-value,.pihole-rate,.pihole-health,.instance-toggle')) { if (!shown(e)) continue; const rects = [...e.getClientRects()]; @@ -46,6 +57,7 @@ async function loadViewport(width, height, scenario, dark) { async function checkLayout(width, height, scenario, dark) { const d = await loadViewport(width, height, scenario, dark); const compact = width <= 767 || width <= 1024 && height <= 500; + assert((d.defaultView.getComputedStyle(d.querySelector('#query-panel')).position === 'fixed') === !compact, 'wrong background/panel presentation'); geometry(d, 'default'); assert(frame.contentWindow.getComputedStyle(d.body).backgroundColor === (dark ? 'rgb(31, 41, 55)' : 'rgb(243, 244, 246)'), 'theme does not match saved preference'); const toggles = [...d.querySelectorAll('.instance-toggle')]; From 53d1ce22bade94d0b572d616d00c34389c55fd7c Mon Sep 17 00:00:00 2001 From: Suraj Verma <5441349+surajverma@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:13:09 +0530 Subject: [PATCH 41/45] Prevent full desktop query feeds from compressing text rows --- README.md | 2 +- css/dashboard.css | 3 ++- sw.js | 2 +- tests/browser/checks.js | 22 ++++++++++++++++++++++ tests/browser/index.html | 2 +- tests/browser/mock.js | 6 ++++++ 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 31584ff..cbc1e87 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Desktop keeps the original 576-pixel-wide dashboard, full metric cards, query ra Compact mode applies below 768 CSS pixels wide, and also at up to 1024 pixels wide when the viewport is 500 pixels high or less (including phone landscape and short resized windows). Below 640 pixels, cards use one column; wider layouts use two. Each details button has an instance-specific accessible name, supports keyboard activation, and preserves its expanded state when resizing. -In compact mode, the query feed occupies its own space below the cards in a collapsible panel with a bounded scrolling area and a Pause control. Desktop retains the original fixed background feed, bottom alignment and fade; it stays behind the opaque cards and pauses on hover. Long values and names wrap instead of overflowing. Short desktop dashboards are centered safely; taller dashboards scroll from the top without clipping the heading or Network Summary. Full details and larger instance lists can require vertical scrolling. +In compact mode, the query feed occupies its own space below the cards in a collapsible panel with a bounded scrolling area and a Pause control. Desktop retains the original fixed background feed, bottom alignment and fade. Query rows keep their full line height as the feed fills, with older rows leaving the top instead of every row being compressed; it stays behind the opaque cards and pauses on hover. Long values and names wrap instead of overflowing. Short desktop dashboards are centered safely; taller dashboards scroll from the top without clipping the heading or Network Summary. Full details and larger instance lists can require vertical scrolling. ### Why the latency number was removed diff --git a/css/dashboard.css b/css/dashboard.css index 2fef5f7..2ab017c 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -52,7 +52,8 @@ html, body { overscroll-behavior-y: contain; } .query-panel { position: fixed; inset: auto 0 0; height: 60vh; max-height: 60vh; z-index: 0; pointer-events: none; } .query-panel-header { display: none; } #background-queries { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; list-style: none; margin: 0; padding: 4px 6px 8px; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 10px; line-height: 1.25; pointer-events: auto; -webkit-mask-image: linear-gradient(to top, #000 0%, #000 50%, #0008 80%, transparent 100%); mask-image: linear-gradient(to top, #000 0%, #000 50%, #0008 80%, transparent 100%); } -#background-queries li { text-shadow: 0 1px 2px rgb(0 0 0 / .15); letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Keep full line boxes when the bounded feed fills; older rows leave the top. */ +#background-queries li { flex-shrink: 0; text-shadow: 0 1px 2px rgb(0 0 0 / .15); letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .query-allowed { color: #16a34a; } .query-blocked { color: #dc2626; } .dark .query-allowed { color: #4ade80; } diff --git a/sw.js b/sw.js index 5faef4d..a283db7 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'pi-dashboard-cache-v4-desktop-background'; +const CACHE_NAME = 'pi-dashboard-cache-v5-query-row-height'; const APP_SHELL = [ '{{CACHE_URL}}', '{{CACHE_URL}}css/style.css', diff --git a/tests/browser/checks.js b/tests/browser/checks.js index 838580a..bea8444 100644 --- a/tests/browser/checks.js +++ b/tests/browser/checks.js @@ -79,6 +79,28 @@ async function checkLayout(width, height, scenario, dark) { assert(shown(d.querySelector('#background-queries')), 'query feed did not open'); geometry(d, 'queries open'); if (scenario !== 'zero') assert(d.querySelector('#background-queries').textContent.includes('(x3)'), 'consecutive grouping missing'); + if (scenario !== 'zero') { + // A short initial feed fits; reproduce a full feed after repeated 50-query polls. + const fixture = frame.contentWindow.fixture; + const name = Object.keys(fixture.queries)[0]; + for (const start of [100, 150]) { + fixture.queries[name] = Array.from({length: 50}, (_, i) => ({ + id: start + i, time: start + i, domain: `busy-${start + i}.example.org`, blocked: i % 3 === 0, + })); + await fixture.tick(3000); + } + const rows = [...d.querySelectorAll('#background-queries li')]; + assert(rows.length === 100, 'full feed did not retain its bounded 100 rows'); + for (const row of rows) { + const lineHeight = parseFloat(d.defaultView.getComputedStyle(row).lineHeight); + assert(bounds(row).height >= lineHeight - .1, `full feed shrinks rows below line height: ${bounds(row).height} < ${lineHeight}`); + } + if (!compact) { + const list = bounds(d.querySelector('#background-queries')); + assert(bounds(rows.at(-1)).bottom <= list.bottom + 1 && bounds(rows.at(-1)).top >= list.top, 'newest query is not visible'); + } + geometry(d, 'full query feed'); + } if (scenario === 'states') { for (const state of ['Blocking ON', 'Blocking OFF', 'Offline', 'Auth failed', 'blocking unknown']) assert(d.querySelector('main').textContent.includes(state), `missing ${state}`); assert(shown(d.querySelector('.network-partial')), 'missing partial summary'); diff --git a/tests/browser/index.html b/tests/browser/index.html index dc4ec9c..fb39a96 100644 --- a/tests/browser/index.html +++ b/tests/browser/index.html @@ -2,7 +2,7 @@ Pi-Dash rendered regressions

    Pi-Dash rendered regressions

    -
    +

    Ready