diff --git a/.dockerignore b/.dockerignore index 9ba0125..d57e381 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,8 +9,12 @@ __pycache__/ *.pyo *.pyd .env +config.json *.log dist/ src/ .vscode/ -.DS_Store \ No newline at end of file +.DS_Store +tests/ +test-results/ +.git/ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 611203f..6cb8386 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,9 +27,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: 'lts/*' + cache: 'npm' - name: Install npm dependencies - run: npm install + run: npm ci - name: Build Tailwind CSS run: npm run build:css diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2961526 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,95 @@ +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 Python dependencies + run: pip install -r requirements.txt + + - name: Prepare test configuration + run: cp config-example.json config.json + + - name: Run backend 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: '22' + cache: 'npm' + + - name: Install frontend dependencies + run: npm ci + + - name: Syntax-check frontend and service worker + run: | + node --check js/dashboard-core.js + node --check js/app.js + node --check sw.js + + - name: Run frontend behavior regressions + run: npm run test:js + + - name: Verify Tailwind build + run: | + npm run build:css + git diff --exit-code -- css/style.css + + - name: Install rendered-test browser + run: npx playwright install --with-deps chromium + + - name: Run rendered viewport and lifecycle regressions + run: npm run test:browser + + - name: Save rendered test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: rendered-dashboard + path: test-results/ + + - name: Verify the shipped HTML, scripts and styles + run: | + test -s css/style.css + test -s css/dashboard.css + test -s js/dashboard-core.js + test -s js/app.js + node -e "const fs=require('fs'); const html=fs.readFileSync('index.html','utf8'); for(const file of ['css/style.css','css/dashboard.css','js/dashboard-core.js','js/app.js']) { if(!html.includes(file)||!fs.existsSync(file)) process.exit(1); }" + + - name: Build Docker image + run: docker build --tag pi-dash:test . + + - name: Smoke-test Docker image + run: | + docker run --detach --name pi-dash-test \ + --publish 127.0.0.1:5001:5001 \ + --volume "$PWD/config-example.json:/app/config.json:ro" \ + pi-dash:test + for attempt in {1..20}; do + if curl --fail --silent http://127.0.0.1:5001/health; then + exit 0 + fi + sleep 1 + done + docker logs pi-dash-test + exit 1 + + - name: Stop Docker smoke-test container + if: always() + run: docker rm --force pi-dash-test || true diff --git a/.gitignore b/.gitignore index 3b9babb..2fdbbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ venv/ config.json node_modules/ -package-lock.json dist/ .env *.log -css/style.css \ No newline at end of file +__pycache__/ +test-results/ 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"] diff --git a/README.md b/README.md index 6ac7aea..03c89d1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![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) @@ -11,63 +12,101 @@ Pi-Dash is a simple, lightweight dashboard for monitoring multiple Pi-hole insta ## 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 Support:** Monitor all your Pi-hole instances from a single dashboard. +- **Network Summary:** View combined query, blocked, cached, and forwarded totals across reporting Pi-holes. +- **Pi-hole Status:** See reachability, authentication errors, and blocking ON/OFF state for each instance. +- **Responsive Design:** Desktop shows the full dashboard and ambient query feed. Mobile shows critical metrics in compact cards, with an accessible control to expand all available metrics. +- **Live Query Feed:** Optionally show recent allowed and blocked domains. Consecutive duplicate queries are grouped with `(x2)`, `(x3)`, and similar counts. +- **Configurable Refresh:** Set separate refresh intervals for statistics and queries. If the query interval is omitted, it uses the statistics interval. +- **Efficient Polling:** Polling stops while the page is hidden or the browser is offline, then resumes safely when it becomes active again. A short shared cache reduces duplicate Pi-hole API requests. +- **Lightweight and Fast:** Built with Flask and vanilla JavaScript, with no database or frontend framework. +- **Dark Mode and PWA Support:** Works with your preferred color scheme and can be installed as a Progressive Web App. ![pi-dash-landscape](https://github.com/user-attachments/assets/a0e1fbef-279a-40df-9424-0cad50c31b50) - -Screenshot 2025-10-04 114711 +Pi-Dash dashboard ## Configuration -Before running the application, you need to create and configure the following files: +Copy `config-example.json` to `config.json` and edit it for your network. The example is valid JSON without comments; all options and their defaults are described below. ### 1. `config.json` -This file manages your Pi-hole instances and dashboard settings. Example: - ```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", - "enabled": true, - "link": true - }, - { - "name": "Secondary", - "address": "https://pi.hole/two", - "password": "your_app_password_here", + "address": "https://pi.hole", + "password": "${PIHOLE_PRIMARY_PASSWORD}", "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 | Description | +| --- | --- | --- | +| `base_path` | `/` | Subpath where Pi-Dash is hosted, for example `/pi-dash/`. | +| `refresh_interval` | `5000` | Statistics refresh interval in milliseconds. | +| `queries_refresh_interval` | `refresh_interval` | Query-feed refresh interval in milliseconds. | +| `cache_ttl` | Automatic | Shared backend cache lifetime in milliseconds. Set to `0` to disable caching. | +| `show_queries` | `false` | Show the live DNS query feed. Allowed queries are green and blocked queries are red. | +| `show_network_summary` | `true` | Show combined statistics from all reporting Pi-holes. | +| `show_trends` | `true` | Show short, in-memory query-rate sparklines. | +| `piholes` | `[]` | List of Pi-hole instances to monitor. | + +When `cache_ttl` is omitted, Pi-Dash calculates it as half of the shortest refresh interval, with a minimum of 100 ms and a maximum of 1000 ms. The values in `config-example.json` are recommended example settings; omitted options use the defaults above. + +#### Pi-hole options + +| Option | Default | Description | +| --- | --- | --- | +| `name` | Required | Display name for the Pi-hole. Names must be unique. | +| `address` | Required | Full Pi-hole base URL, including the scheme and optional port. Do not include `/admin` or `/api`. | +| `password` | Empty string | Pi-hole API/application password. Literal values and `${ENV_NAME}` references are supported. | +| `enabled` | `true` | Set to `false` to hide and stop monitoring an instance without deleting it from the configuration. | +| `link` | `false` | Make the Pi-hole name a link to its admin interface. | +| `verify_ssl` | `false` | Set to `true` to verify trusted HTTPS certificates, or provide a CA bundle path. | + +Settings omitted from `config.json` use the defaults above, so existing configurations continue to work. Existing installations may also continue storing the password directly in `config.json`: + +```json +"password": "your_app_password_here" +``` + +For new installations, the password can instead be kept outside `config.json` by referencing an environment variable: + +```json +"password": "${PIHOLE_PRIMARY_PASSWORD}" +``` + +With Docker Compose, add the corresponding value to a `.env` file beside `compose.yaml`: + +```dotenv +PIHOLE_PRIMARY_PASSWORD=your_app_password_here +``` + +The Compose example below passes this variable to Pi-Dash. For Docker Run, use `--env-file .env`; for a native installation, export the variable in your shell before starting Pi-Dash. The same `${ENV_NAME}` syntax can be used for a CA bundle path. The `.env` file is ignored by this repository and should not be committed to GitHub. + +The Network Summary includes only additive DNS counters: total queries, blocked queries, cached queries, and forwarded queries. It does not combine active clients, unique domains, or domains on lists because those values can overlap between Pi-holes. If an instance is unavailable, the summary is marked as partial. + +When the query feed is enabled, it displays recent queries while the dashboard is visible and online. Only consecutive entries with the same Pi-hole, domain, and blocked/allowed state are grouped. The feed is intended as a live overview; use Pi-hole's query log for complete history. ### 2. `manifest.json` (Optional) -This file is for Progressive Web App (PWA) settings and icon. Example: +This file contains the Progressive Web App name, colors, and icon: ```json { @@ -88,62 +127,87 @@ This file is for Progressive Web App (PWA) settings and icon. Example: } ``` -- **icons.src**: Replace with a direct link to your Pi-hole logo or preferred icon. +Replace `icons.src` with a direct link to your Pi-hole logo or preferred icon. --- ## 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 +### Docker Compose + +```yaml +services: + pi-dash: + image: ghcr.io/surajverma/pi-dash:latest + container_name: pi-dash + ports: + - 5001:5001 + environment: + PIHOLE_PRIMARY_PASSWORD: "${PIHOLE_PRIMARY_PASSWORD}" + volumes: + - ./config.json:/app/config.json:ro + - ./manifest.json:/app/manifest.json:ro + restart: unless-stopped +``` + +### Docker Run + +```bash +docker run -d \ + --name pi-dash \ + -p 5001:5001 \ + --env-file .env \ + -v /path/to/pi-dash/config.json:/app/config.json:ro \ + -v /path/to/pi-dash/manifest.json:/app/manifest.json:ro \ + 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. **Docker Run** + +2. Create your configuration and install the dependencies: + ```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 + cp config-example.json config.json + python -m pip install -r requirements.txt ``` -### Native Install + On Windows, use `copy config-example.json config.json` instead. + +3. Start Pi-Dash: + + ```bash + python proxy.py + ``` -1. **Clone the repository:** +Open `http://localhost:5001` in your browser. - ```bash - git clone https://github.com/surajverma/pi-dash.git - cd pi-dash - ``` +## Health Check -2. **Install the dependencies:** +`GET /health` reports whether the Pi-Dash application is running. It does not contact the configured Pi-hole instances. - ```bash - pip install -r requirements.txt - ``` +## Development -3. **Start the app:** - To start the application, run the following command from the project's root directory: - ```bash - python proxy.py - ``` +The automated tests use mocked Pi-hole responses and do not require a live Pi-hole: -Then, open your web browser and navigate to `http://localhost:5001`. +```bash +python -m unittest discover -s tests -v +npm ci +npm run test:js +npm run build:css +npx playwright install chromium +npm run test:browser +``` ## 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 @@ -158,4 +222,5 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. ## Thank You + If you like my work, you can [buy me a coffee ☕](https://ko-fi.com/skv) diff --git a/config-example.json b/config-example.json index 7673df5..47cea25 100644 --- a/config-example.json +++ b/config-example.json @@ -1,21 +1,19 @@ { "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 } ] -} \ No newline at end of file +} diff --git a/css/dashboard.css b/css/dashboard.css new file mode 100644 index 0000000..2ab017c --- /dev/null +++ b/css/dashboard.css @@ -0,0 +1,104 @@ +body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-tap-highlight-color: transparent; } +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: 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; } +.status-success { background: #22c55e; } +.status-warning { background: #eab308; } +.status-danger { background: #ef4444; } +.status-neutral { background: #9ca3af; } +.status-text-success { color: #16a34a; } +.status-text-warning { color: #ca8a04; } +.status-text-danger { color: #dc2626; } +.status-text-neutral { color: #6b7280; } +.dark .status-text-success { color: #4ade80; } +.dark .status-text-warning { color: #facc15; } +.dark .status-text-danger { color: #f87171; } +.dark .status-text-neutral { color: #9ca3af; } +#last-updated.is-stale { color: #ca8a04; } +.network-heading { display: flex; justify-content: space-between; align-items: baseline; gap: .75rem; margin-bottom: .75rem; } +.network-health { text-align: right; } +.network-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; } +.network-metrics > div { min-width: 0; } +.network-metrics { overflow-wrap: anywhere; } +.network-partial { font-size: .75rem; color: #b45309; margin: 0 0 .75rem; } +.dark .network-partial { color: #fbbf24; } +#network-summary.is-partial { border-left: 3px solid #f59e0b; } +.instance-card { background: white; padding: 1rem; border-radius: .5rem; box-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1); min-width: 0; } +.dark .instance-card { background: #111827; } +.instance-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: .5rem; margin-bottom: .75rem; } +.instance-identity { min-width: 0; } +.instance-name { font-size: 1.25rem; font-weight: 600; color: #374151; line-height: 1.4; overflow-wrap: anywhere; } +.dark .instance-name { color: #22d3ee; } +.instance-name a:hover, .instance-name a:focus { color: #14b8a6; } +.pihole-rate { font-size: .875rem; font-weight: 400; color: #6b7280; overflow-wrap: anywhere; } +.dark .pihole-rate { color: #9ca3af; } +.instance-health { display: flex; align-items: center; gap: .35rem; font-size: .75rem; margin-top: .15rem; } +.pihole-sparkline { width: 80px; height: 28px; color: #0d9488; flex: none; } +.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: 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 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%); } +/* 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; } +.dark .query-blocked { color: #f87171; } +@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) { + body { display: block; padding: .75rem; } + #main-wrapper { max-width: none; width: 100%; margin: 0 auto; transform: none !important; } + #main-wrapper > header { margin-bottom: .75rem; } + #main-wrapper > header h1 { font-size: 1.375rem; line-height: 1.75rem; } + #network-summary { padding: .75rem; margin-bottom: .75rem; } + .network-heading { margin-bottom: .5rem; align-items: flex-start; } + .network-health { font-size: .6875rem; max-width: 56%; } + .network-metrics { gap: .5rem .75rem; font-size: .875rem; } + .network-metrics > div > div:first-child { font-size: .6875rem; } + main { gap: .75rem !important; } + .instance-card { padding: .75rem; } + .instance-heading { margin-bottom: .5rem; } + .instance-name { font-size: 1rem; line-height: 1.4; } + .pihole-rate { font-size: .75rem; } + .instance-health { font-size: .6875rem; } + .pihole-sparkline { width: 58px; height: 22px; } + .mobile-instance-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem; } + .mobile-metric { display: flex; flex-direction: column; gap: .1rem; min-width: 0; } + .mobile-metric .metric-label { font-size: .6875rem; } + .mobile-metric .metric-value { text-align: left; font-size: .875rem; } + .instance-toggle { display: block; width: 100%; margin-top: .625rem; padding: .375rem 0; min-height: 2.75rem; border-top: 1px solid #e5e7eb; color: #0f766e; font-size: .75rem; font-weight: 500; text-align: center; } + .dark .instance-toggle { border-color: #374151; color: #5eead4; } + .instance-details { display: none; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid #e5e7eb; font-size: .8125rem; gap: .45rem; } + .dark .instance-details { border-color: #374151; } + .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 { 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 { 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; white-space: normal; overflow-wrap: anywhere; } +} +@media (max-width: 639px) { + #main-wrapper > main { grid-template-columns: minmax(0, 1fr); } +} +@media (prefers-reduced-motion: reduce) { body { transition: none; } } diff --git a/css/style.css b/css/style.css index d8f66f7..302e668 100644 --- a/css/style.css +++ b/css/style.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.relative{position:relative}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-full{height:100%}.w-full{width:100%}.max-w-sm{max-width:24rem}.translate-y-1{--tw-translate-y:0.25rem}.transform,.translate-y-1{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.whitespace-nowrap{white-space:nowrap}.rounded-lg{border-radius:.5rem}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[10px\]{font-size:10px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.leading-tight{line-height:1.25}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-teal-500{--tw-text-opacity:1;color:rgb(20 184 166/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.opacity-0{opacity:0}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.drop-shadow,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}body{font-family:Inter,sans-serif;-webkit-tap-highlight-color:transparent}body,html{overscroll-behavior-y:contain}.status-dot{width:10px;height:10px;border-radius:50%;display:inline-block}#background-queries{position:fixed;bottom:0;left:0;right:0;top:auto;height:60vh;max-height:60vh;display:flex;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;pointer-events:none;overflow:hidden;z-index:0;-webkit-mask-image:linear-gradient(0deg,#000 0,#000 50%,rgba(0,0,0,.5) 80%,transparent);mask-image:linear-gradient(0deg,#000 0,#000 50%,rgba(0,0,0,.5) 80%,transparent);backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}#background-queries li{text-shadow:0 1px 2px rgba(0,0,0,.15);letter-spacing:.01em}@keyframes bqFadeIn{to{opacity:.9;transform:translateY(0)}}@media (max-width:640px) and (orientation:portrait){#background-queries{height:42vh;max-height:42vh}body:has(#background-queries:not(:empty)) #main-wrapper{transform:translateY(-8vh);transition:transform .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}body:has(#background-queries:not(:empty)) #main-wrapper{transform:translateY(-4vh);transition:transform .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}}.focus\:text-teal-500:focus,.hover\:text-teal-500:hover{--tw-text-opacity:1;color:rgb(20 184 166/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:max-w-xl{max-width:36rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.dark\:text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.dark\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.dark\:text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.dark\:text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.dark\:text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.relative{position:relative}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-6{margin-bottom:1.5rem}.grid{display:grid}.hidden{display:none}.w-full{width:100%}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.gap-4{gap:1rem}.rounded-lg{border-radius:.5rem}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.p-4{padding:1rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:text-cyan-400:is(.dark *){--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-yellow-500:is(.dark *){--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}} \ No newline at end of file diff --git a/index.html b/index.html index df552c5..8f0aa68 100644 --- a/index.html +++ b/index.html @@ -1,25 +1,16 @@ - + - + Pi-hole Dashboard - - - - - - - + - -
+ +

Pi-Dash

Last updated: Never

- +
- - - + + - \ No newline at end of file + diff --git a/js/app.js b/js/app.js index d13df20..c73da18 100644 --- a/js/app.js +++ b/js/app.js @@ -1,381 +1,350 @@ document.addEventListener('DOMContentLoaded', () => { - let refreshIntervalId = null; - let stalenessIntervalId = null; + '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 isFetching = false; - let lastUpdateTime = null; - let queryFeedPaused = false; - const lastCursorByPihole = {}; - - async function fetchData(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return await response.json(); + let statsTimer = null, queriesTimer = null, stalenessTimer = null; + let statsController = null, queriesController = null, initController = null; + let lastUpdateTime = null, manualPause = false, hoverPause = 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 => { + const parsed = Number(value); + return (Number.isFinite(parsed) ? parsed : 0).toLocaleString(); + }; + + function visible() { + return !document.hidden && navigator.onLine !== false; } - 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; - - return { - total: Number(queries.total) || 0, - blocked: Number(queries.blocked) || 0, - percentage: Number(queries.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, - domains: Number(gravity.domains_being_blocked) || 0, - }; + 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; } - async function updatePiholeUI(piholeName, rawData) { - const section = document.getElementById(`pihole-${piholeName}-section`); - if (!section) return; + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } - 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 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 { - if (rawData.error) throw new Error(rawData.error); + 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; } + } - const stats = processSummaryData(rawData); + 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.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); + return section; + } - 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 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); + 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); + } - 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(); + 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); + } - 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) { + 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(); - 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'); + timestamp.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; + timestamp.classList.remove('is-stale'); } - - 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'); - } + timestamp.classList.toggle('is-stale', Date.now() - lastUpdateTime > appConfig.refresh_interval * 2); } - async function refreshDashboard() { - if (isFetching) return; - isFetching = 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); - } - updateTimestamp(); - - - if (queriesData) { - renderQueries(queriesData); - } - } catch (error) { - console.error('Failed to refresh dashboard:', error); - } finally { - isFetching = false; + 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); + queryContainer.scrollTop = queryContainer.scrollHeight; } - function startTimer() { - if (refreshIntervalId || !appConfig) return; - const interval = appConfig.refresh_interval || 5000; - refreshIntervalId = setInterval(refreshDashboard, interval); - stalenessIntervalId = setInterval(updateStalenessIndicator, 1000); + function isQueryPaused() { return manualPause || hoverPause; } + function flushQueries() { + if (isQueryPaused() || !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 stopTimer() { - clearInterval(refreshIntervalId); - refreshIntervalId = null; - clearInterval(stalenessIntervalId); - stalenessIntervalId = null; + 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 renderQueries(allQueries) { - const container = document.getElementById('background-queries'); - if (!container) return; - const isDark = document.documentElement.classList.contains('dark'); - container.classList.toggle('dark-mode', isDark); - - 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; - } - pendingCursors[piholeName] = maxCursor; - } - - if (newItems.length === 0) return; - if (queryFeedPaused) return; - - // Commit cursor advances only now that we know we will render - Object.assign(lastCursorByPihole, pendingCursors); + function updatePauseState() { + pauseButton.textContent = manualPause ? 'Resume' : 'Pause'; + pauseButton.setAttribute('aria-pressed', String(manualPause)); + if (!isQueryPaused()) flushQueries(); + } - newItems.sort((a, b) => a.__cursor - b.__cursor); + function canApply(generation, signal) { + return generation === foregroundGeneration && visible() && !signal.aborted; + } - 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 }); - } + async function refreshStats() { + if (!appConfig || !visible() || (statsController && !statsController.signal.aborted)) return; + 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 (canApply(generation, controller.signal) && error.name !== 'AbortError') console.error('Failed to refresh stats:', error); + } finally { + if (statsController === controller) statsController = null; } + } - 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); - } + async function refreshQueries() { + if (!appConfig?.show_queries || !visible() || (queriesController && !queriesController.signal.aborted)) return; + 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 (canApply(generation, controller.signal) && error.name !== 'AbortError') console.error('Failed to refresh queries:', error); + } finally { + if (queriesController === controller) queriesController = null; } + } - while (container.children.length > MAX_ROWS) { - container.removeChild(container.firstChild); - } + function stopTimers() { + foregroundGeneration++; + clearInterval(statsTimer); clearInterval(queriesTimer); clearInterval(stalenessTimer); + statsTimer = queriesTimer = stalenessTimer = null; + initController?.abort(); statsController?.abort(); queriesController?.abort(); + initController = statsController = queriesController = null; + } + + 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() || (initController && !initController.signal.aborted)) return; + const generation = foregroundGeneration; + const controller = new AbortController(); + initController = controller; 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', 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(); - - if (initData.queries) { - renderQueries(initData.queries); - } - - startTimer(); + startTimers(); } catch (error) { - console.error('Failed to initialize dashboard:', error); + if (canApply(generation, controller.signal) && error.name !== 'AbortError') { + console.error('Failed to initialize dashboard:', error); + timestamp.textContent = 'Unable to initialize dashboard'; + } + } finally { + if (initController === controller) initController = null; + // A page can become visible before an aborted initialization settles. + // Restart only if no newer initialization has already taken ownership. + if (!appConfig && visible() && generation !== foregroundGeneration && !initController) init(); } } + function resumeForeground() { + if (!visible()) return; + if (!appConfig) { init(); return; } + refreshStats(); refreshQueries(); startTimers(); + } document.addEventListener('visibilitychange', () => { - if (document.hidden) { - stopTimer(); - } else { - refreshDashboard(); - startTimer(); - } + if (document.hidden) stopTimers(); else resumeForeground(); }); - - 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', 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', () => { manualPause = !manualPause; updatePauseState(); }); + if (window.matchMedia?.('(hover: hover) and (pointer: fine)').matches) { + queryContainer.addEventListener('mouseenter', () => { hoverPause = true; updatePauseState(); }); + queryContainer.addEventListener('mouseleave', () => { hoverPause = false; updatePauseState(); }); } - + 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 diff --git a/js/dashboard-core.js b/js/dashboard-core.js new file mode 100644 index 0000000..9df7efa --- /dev/null +++ b/js/dashboard-core.js @@ -0,0 +1,151 @@ +/* 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') { + 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') { + 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?.auth_error_instances) parts.push(`${summary.auth_error_instances} auth failed`); + 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?.auth_error_instances && !summary?.offline_instances && !summary?.blocking_unknown_instances) { + return `${total} healthy`; + } + 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)}`; + } + 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) { + 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) || { id: null, time: -Infinity }; + let seen = tracker.seen.get(piholeName) || new Map(); + const valid = queries.filter(q => q && typeof q === 'object'); + 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(); + } + 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 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; + 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 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; + } + + return { + summaryStats, formatRate, healthPresentation, networkStatus, + queryIdentity, queryTimestamp, queryKey, queryLabel, + groupConsecutiveQueries, mergeConsecutiveGroups, + createQueryTracker, collectNewQueries, + }; +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e358ef2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1576 @@ +{ + "name": "pi-dash", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-dash", + "version": "1.0.0", + "devDependencies": { + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "tailwindcss": "^3.3.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index dc911b2..479d3c8 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,14 @@ "version": "1.0.0", "private": true, "devDependencies": { + "jsdom": "^26.1.0", + "playwright": "^1.63.0", "tailwindcss": "^3.3.0" }, "scripts": { - "build:css": "npx tailwindcss -c tailwind.config.js -i ./src/tailwind.css -o ./css/style.css --minify" + "build:css": "tailwindcss -c tailwind.config.js -i ./src/tailwind.css -o ./css/style.css --minify", + "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 c4084e4..bd14a91 100644 --- a/proxy.py +++ b/proxy.py @@ -1,334 +1,451 @@ 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_REQUEST_TIMEOUT = 10 +DEFAULT_CACHE_TTL = 1000 +DEFAULT_BLOCKING_TTL = 30000 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' + '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) - -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() +def _load_json(name): + with open(os.path.join(APP_ROOT, name), encoding='utf-8') as handle: + return json.load(handle) -with open(os.path.join(APP_ROOT, 'sw.js')) as f: - sw_template = f.read() +config = _load_json('config.json') +manifest_data = _load_json('manifest.json') +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 -if url_prefix != '/' and url_prefix.endswith('/'): - url_prefix = url_prefix[:-1] - -html_base = base_path_config -if not html_base.endswith('/'): - html_base += '/' - +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_locks_lock = threading.Lock() +_blocking_locks = {} +_stats_cache = {'time': 0.0, 'data': None} +_queries_cache = {} +_blocking_cache = {} -def authenticate_and_get_sid(address, password): - auth_url = f"{address}/api/auth" - payload = {"password": password} +def _int(value, default, minimum=100): try: - response = requests.post(auth_url, json=payload, timeout=10, verify=False) - if response.status_code == 200: - data = response.json() - 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.") - 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.") - return None - else: - print(f"Authentication failed for {address} with status code {response.status_code}. Response: {response.text}") - return None - except requests.exceptions.RequestException as e: - print(f"A network error occurred during authentication with {address}: {e}") - return None + value = int(value) + return value if value >= minimum else default + except (TypeError, ValueError): + return default + -# -- Frontend Routes -- +def _bounded_int(value, default, minimum, maximum): + try: + return max(minimum, min(int(value), maximum)) + 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_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)) + + +def resolve_secret(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): + 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('/') + 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): + 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(pihole) + if sid: + with _sessions_lock: + pihole_sessions[name] = sid + return sid + + +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_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}') - final_html = temp_html.replace('{{ICON_URL}}', escape(icon_url)) - - return final_html + 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) + data = manifest_data.copy() + data['start_url'] = html_base + return jsonify(data) + @bp.route('/sw.js') def serve_sw(): - - sw_content = sw_template.replace('{{CACHE_URL}}', html_base) - return sw_content, 200, {'Content-Type': 'application/javascript'} + 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) + @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 -- -def get_filtered_config(): - - piholes_filtered = [] - for p in config["piholes"]: - if not p.get("enabled", True): - continue - item = { - "name": p["name"], - "enabled": p["enabled"], - "link": p.get("link", False) - } - if item["link"]: - - item["address"] = p["address"] - piholes_filtered.append(item) + +# -- API helpers -- +def enabled(): + return [p for p in config.get('piholes', []) if p.get('enabled', True)] + + +def filtered_config(): + 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": 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': out, } -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 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 _blocking_lock_for(name): + with _blocking_locks_lock: + return _blocking_locks.setdefault(name, threading.Lock()) + + +def get_blocking(pihole): + name = pihole['name'] + now = time.monotonic() + with _cache_lock: + cached = _blocking_cache.get(name) + if cached and now - cached['time'] < DEFAULT_BLOCKING_TTL / 1000: + return cached['value'] + with _blocking_lock_for(name): + # Coalesce misses for the same Pi-hole without delaying other instances. + now = time.monotonic() + with _cache_lock: + cached = _blocking_cache.get(name) + if cached and now - cached['time'] < DEFAULT_BLOCKING_TTL / 1000: + return cached['value'] 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 + value = normalize_blocking(pihole_get(pihole, '/api/dns/blocking').json().get('blocking')) + except (requests.exceptions.RequestException, RuntimeError, ValueError): + value = None + with _cache_lock: + _blocking_cache[name] = {'time': time.monotonic(), 'value': value} + return value + + +def fetch_one(pihole): + name = pihole['name'] + try: + 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', } - - 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, ValueError) as exc: + 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(): + 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_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_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: + 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: + return _stats_cache['data'] + data = _stats_uncached() + with _cache_lock: + _stats_cache.update(time=time.monotonic(), data=data) + return data + + +def _queries_uncached(length): + items = enabled() 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 - 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'] + if not items: + return results + hosts = {urlparse(p['address']).hostname.lower() for p in items if urlparse(p['address']).hostname} - 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}" + def one(pihole): 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() - - normalized = [] - for q in data.get('queries', [])[:length]: - original_domain = q.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: + data = pihole_get(pihole, '/api/queries', {'length': length}).json() + out = [] + for query in data.get('queries', [])[:length]: + domain = query.get('domain', '') + timestamp = query.get('time') if query.get('time') is not None else query.get('timestamp') + if domain.lower().strip() in hosts: continue - - # Check if status matches any blocked status - blocked = status in BLOCKED_STATUSES - - normalized.append({ - 'id': qid, - 'domain': original_domain, # preserve original casing for display - 'blocked': blocked, - 'time': ts, - 'timestamp': ts, # legacy compatibility - 'upstream': upstream + out.append({ + 'id': query.get('id'), + 'domain': domain, + 'blocked': (query.get('status') or '').upper() in BLOCKED_STATUSES, + 'time': timestamp, + 'timestamp': timestamp, + 'upstream': query.get('upstream', ''), }) - - return name, normalized - except requests.exceptions.RequestException: - 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): + return pihole['name'], out + except (requests.exceptions.RequestException, RuntimeError, ValueError): + return pihole['name'], [] + + with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(items), 10)) as executor: + futures = [executor.submit(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_queries(length=50, force=False): + ttl = get_cache_ttl() / 1000 + now = time.monotonic() + with _cache_lock: + cached = _queries_cache.get(length) + if not force and cached and now - cached['time'] < ttl: + 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: + return cached['data'] + data = _queries_uncached(length) + with _cache_lock: + _queries_cache[length] = {'time': time.monotonic(), 'data': data} + return data + + +def network_summary(data): + total = blocked = cached = forwarded = healthy = offline = auth_errors = disabled = unknown = 0 + contributing = 0 + for item in data.values(): + if item.get('error') or not isinstance(item.get('queries'), dict): + if item.get('_pi_dash', {}).get('health') == 'auth_error': + auth_errors += 1 + else: + offline += 1 + continue + contributing += 1 + queries = item['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) + blocking = item.get('_pi_dash', {}).get('blocking') + if blocking is False: + disabled += 1 + elif blocking is None: + unknown += 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(data), + 'contributing_instances': contributing, + 'healthy_instances': healthy, + 'blocking_disabled_instances': disabled, + 'blocking_unknown_instances': unknown, + 'auth_error_instances': auth_errors, + 'offline_instances': offline, + 'partial': contributing != len(data), + } + + +# -- 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 - } - 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 + stats = fetch_stats() + result = {'config': filtered_config(), 'data': stats, 'summary': network_summary(stats)} + if config.get('show_queries', False): + result['queries'] = fetch_queries(50) + return jsonify(result) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + @bp.route('/data') def data(): try: - pihole_data = fetch_all_pihole_data() - - + stats = fetch_stats() + 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(stats) + result = {'stats': stats} + if include_summary: + result['summary'] = network_summary(stats) 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 = _bounded_int(request.args.get('length'), 50, 1, 200) + result['queries'] = fetch_queries(length) + return jsonify(result) + 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 = _bounded_int(request.args.get('length'), 50, 1, 200) + return jsonify(fetch_queries(length)) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@bp.route('/health') +def health(): + return jsonify({'status': 'ok', 'enabled_piholes': len(enabled()), 'cache_ttl_ms': get_cache_ttl()}) app.register_blueprint(bp, url_prefix=url_prefix) +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) diff --git a/requirements.txt b/requirements.txt index f0e3d82..3d28282 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,4 @@ -blinker==1.9.0 -certifi==2025.8.3 -charset-normalizer==3.4.2 -click==8.2.1 -colorama==0.4.6 Flask==3.1.3 -flask-cors==6.0.1 -idna==3.15 -itsdangerous==2.2.0 -Jinja2==3.1.6 MarkupSafe==3.0.2 requests==2.33.0 -urllib3==2.7.0 -Werkzeug==3.1.5 -requests==2.32.4 -urllib3==2.7.0 -Werkzeug==3.1.6 +gunicorn==23.0.0 diff --git a/src/tailwind.css b/src/tailwind.css index 1935b7a..b5c61c9 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -1,86 +1,3 @@ @tailwind base; @tailwind components; @tailwind utilities; - -/* --- Your custom CSS below --- */ -body { - font-family: 'Inter', sans-serif; - -webkit-tap-highlight-color: transparent; -} -html, body { - overscroll-behavior-y: contain; -} -.status-dot { - width: 10px; - height: 10px; - border-radius: 50%; - display: inline-block; -} - - -#background-queries { - position: fixed; - bottom: 0; left: 0; right: 0; top: auto; - height: 60vh; max-height: 60vh; - display: flex; - 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; - 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; - } - - 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; - } - - 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; - } -} \ No newline at end of file diff --git a/sw.js b/sw.js index de521ee..a283db7 100644 --- a/sw.js +++ b/sw.js @@ -1,40 +1,54 @@ -const CACHE_NAME = 'pi-dashboard-cache-v0.6.0'; -const urlsToCache = [ +const CACHE_NAME = 'pi-dashboard-cache-v5-query-row-height'; +const APP_SHELL = [ '{{CACHE_URL}}', '{{CACHE_URL}}css/style.css', + '{{CACHE_URL}}css/dashboard.css', + '{{CACHE_URL}}js/dashboard-core.js', '{{CACHE_URL}}js/app.js', ]; self.addEventListener('install', event => { - event.waitUntil( - caches.open(CACHE_NAME) - .then(cache => { - return cache.addAll(urlsToCache); - }) - ); + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(APP_SHELL))); + self.skipWaiting(); }); self.addEventListener('activate', event => { event.waitUntil( - caches.keys().then(cacheNames => - Promise.all( - cacheNames - .filter(name => name !== CACHE_NAME) - .map(name => caches.delete(name)) - ) - ) + caches.keys().then(names => Promise.all(names.filter(name => name.startsWith('pi-dashboard-cache-') && name !== CACHE_NAME).map(name => caches.delete(name)))) ); + self.clients.claim(); }); self.addEventListener('fetch', event => { - event.respondWith( - caches.open(CACHE_NAME).then(cache => - cache.match(event.request).then(response => { - if (response) { - return response; + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + const base = new URL('{{CACHE_URL}}', self.location.origin).pathname; + if (!url.pathname.startsWith(base)) return; + const relative = url.pathname.slice(base.length); + // API responses contain live DNS information and must never enter CacheStorage. + if (['init', 'data', 'queries', 'health'].includes(relative)) return; + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).then(response => { + if (response.ok) { + const copy = response.clone(); + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy))); } - return fetch(event.request); - }) - ) + return response; + }).catch(() => caches.match(event.request).then(response => response || caches.match('{{CACHE_URL}}'))) + ); + return; + } + // Network-first avoids serving an old JS/CSS bundle after a container upgrade. + if (!APP_SHELL.some(asset => new URL(asset, self.location.origin).pathname === url.pathname)) return; + event.respondWith( + fetch(event.request).then(response => { + if (response.ok) { + const copy = response.clone(); + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy))); + } + return response; + }).catch(() => caches.match(event.request)) ); }); 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..f1871e4 --- /dev/null +++ b/tests/browser/checks.js @@ -0,0 +1,185 @@ +/* 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 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`); + 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()]; + 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; + 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')]; + 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 !== '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}`); + const networkHealth = d.querySelector('.network-health').textContent; + assert(networkHealth.includes('1 offline') && networkHealth.includes('1 auth failed'), 'network summary merges offline and authentication failures'); + 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..fb39a96 --- /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..4e1e132 --- /dev/null +++ b/tests/browser/mock.js @@ -0,0 +1,62 @@ +/* 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' ? 1 : 0, auth_error_instances: scenario === 'states' ? 1 : 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); + }); + if (scenario === 'busy') document.addEventListener('DOMContentLoaded', () => setTimeout(async () => { + for (const start of [100, 150]) { + queries[names[0]] = Array.from({length: 50}, (_, i) => ({id: start + i, time: start + i, domain: `busy-${start + i}.example.org`, blocked: i % 3 === 0})); + await window.fixture.tick(3000); + } + }, 50)); + 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..57e532b --- /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, '127.0.0.1', () => console.log(`Rendered regression suite: http://localhost:${port}/tests/browser/`)); diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs new file mode 100644 index 0000000..0f4a0df --- /dev/null +++ b/tests/dashboard.test.cjs @@ -0,0 +1,175 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); +const core = require('../js/dashboard-core.js'); + +const root = path.resolve(__dirname, '..'); +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); +const sample = { + queries: { total: 100, blocked: 25, percent_blocked: 25, frequency: 2, cached: 40, forwarded: 35, unique_domains: 20 }, + clients: { active: 4 }, gravity: { domains_being_blocked: 1000 }, + _pi_dash: { blocking: true, health: 'healthy' }, +}; +const event = (id, domain, time = id, blocked = false) => ({ id, domain, time, blocked }); + +function makeBrowser(options = {}) { + const html = read('index.html').replace(//g, '').replace('{{ICON_URL}}', ''); + const dom = new JSDOM(html, { url: 'http://localhost:5001/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + let hidden = false, online = true; + let nextTimer = 0; + const timers = new Map(); + const calls = []; + const config = { + refresh_interval: 2000, queries_refresh_interval: 3000, + show_queries: true, show_network_summary: true, show_trends: true, + piholes: [{ name: 'One', enabled: true, link: false }, { name: 'Zero', enabled: true, link: false }], + ...options, + }; + const snapshots = { One: sample, Zero: sample }; + const summary = { total_queries: 200, blocked_queries: 50, percent_blocked: 25, cached_queries: 80, forwarded_queries: 70, instances: 2, contributing_instances: 2, healthy_instances: 2, blocking_disabled_instances: 0, blocking_unknown_instances: 0, offline_instances: 0, partial: false }; + let queries = { One: [event(1, 'example.org', 1), event(2, 'example.org', 2)], Zero: [] }; + Object.defineProperty(window.document, 'hidden', { configurable: true, get: () => hidden }); + Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => online }); + window.setInterval = (fn, ms) => { const id = ++nextTimer; timers.set(id, { fn, ms }); return id; }; + window.clearInterval = id => timers.delete(id); + window.fetch = async (url, request = {}) => { + calls.push({ url: String(url), signal: request.signal }); + let data; + if (String(url) === 'init') data = { config, data: snapshots, summary, queries }; + else if (String(url).startsWith('data?')) data = { stats: snapshots, summary }; + else if (String(url).startsWith('queries?')) data = queries; + else throw new Error('Unexpected URL: ' + url); + return { ok: true, json: async () => data }; + }; + window.eval(read('js/dashboard-core.js')); + window.eval(read('js/app.js')); + const settle = async () => { await new Promise(resolve => window.setTimeout(resolve, 0)); }; + return { + dom, window, calls, timers, settle, config, + setHidden(value) { hidden = value; window.document.dispatchEvent(new window.Event('visibilitychange')); }, + setOnline(value) { online = value; window.dispatchEvent(new window.Event(value ? 'online' : 'offline')); }, + setQueries(value) { queries = value; }, + async tick(ms) { for (const timer of [...timers.values()].filter(t => t.ms === ms)) await timer.fn(); await settle(); }, + close() { dom.window.close(); }, + }; +} + +test('consecutive grouping retains real events, separates blocked status and merges across batches', () => { + const items = [event(1, 'a.test'), event(2, 'a.test'), event(3, 'b.test'), event(4, 'a.test')].map(q => ({ ...q, piholeName: 'One' })); + const groups = core.groupConsecutiveQueries(items); + assert.deepEqual(groups.map(g => g.count), [2, 1, 1]); + const more = core.groupConsecutiveQueries([{ ...event(5, 'a.test'), piholeName: 'One' }]); + assert.deepEqual(core.mergeConsecutiveGroups(groups, more).map(g => g.count), [2, 1, 2]); + assert.equal(core.groupConsecutiveQueries([{ ...items[0], blocked: false }, { ...items[1], blocked: true }]).length, 2); + assert.equal(core.mergeConsecutiveGroups(groups, more, 2).length, 2); +}); + +test('query tracker handles overlapping polls and independent database IDs', () => { + const tracker = core.createQueryTracker(); + const first = core.collectNewQueries(tracker, { One: [event(10, 'a.test', 100), event(11, 'b.test', 101)], Zero: [event(900, 'z.test', 99)] }); + assert.deepEqual(first.map(q => q.domain), ['z.test', 'a.test', 'b.test']); + assert.equal(core.collectNewQueries(tracker, { One: [event(10, 'a.test', 100), event(11, 'b.test', 101)], Zero: [event(900, 'z.test', 99)] }).length, 0); + assert.deepEqual(core.collectNewQueries(tracker, { One: [event(12, 'c.test', 102)], Zero: [event(901, 'd.test', 103)] }).map(q => q.domain), ['c.test', 'd.test']); + assert.equal(core.queryIdentity({ id: null, time: 1, type: 'A', client: { ip: '1.2.3.4' }, domain: 'a' }) === core.queryIdentity({ id: null, time: 1, type: 'AAAA', client: { ip: '1.2.3.4' }, domain: 'a' }), false); +}); + +test('health presentation does not infer network speed from API latency', () => { + assert.equal(core.healthPresentation({ blocking: true, latency_ms: 1000 }).text, 'Blocking ON'); + assert.equal(core.healthPresentation({ blocking: false }).text, 'Blocking OFF'); + assert.equal(core.healthPresentation({ health: 'auth_error' }, true).text, 'Auth failed'); + assert.match(core.networkStatus({ instances: 2, contributing_instances: 1, offline_instances: 1, partial: true }), /1 of 2 reporting/); + assert.match(core.networkStatus({ instances: 2, contributing_instances: 0, offline_instances: 1, auth_error_instances: 1, partial: true }), /1 auth failed/); +}); + +test('frequently updated timestamp is not an ARIA live region', async () => { + const app = makeBrowser(); + try { + await app.settle(); + assert.equal(app.window.document.getElementById('last-updated').hasAttribute('role'), false); + } finally { app.close(); } +}); + +test('mobile card starts compact and expands all desktop metrics', async () => { + const app = makeBrowser(); + try { + await app.settle(); + const doc = app.window.document; + const card = doc.querySelector('.instance-card'); + assert.equal(card.querySelectorAll('.mobile-metric').length, 3); + assert.equal(card.querySelectorAll('.instance-details .metric-row').length, 7); + assert.equal(card.querySelector('.pihole-latency'), null); + const toggle = card.querySelector('.instance-toggle'); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + toggle.click(); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(card.classList.contains('is-expanded'), true); + assert.match(card.textContent, /Cached \/ Forwarded/); + toggle.click(); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(doc.querySelector('#network-summary').hidden, false); + } finally { app.close(); } +}); + +test('the feed restores xN grouping and stays compact on mobile', async () => { + const app = makeBrowser(); + try { + await app.settle(); + const doc = app.window.document; + let rows = [...doc.querySelectorAll('#background-queries li')]; + assert.equal(rows.length, 1); + assert.match(rows[0].textContent, /\(x2\)/); + const toggle = doc.getElementById('query-toggle'); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + toggle.click(); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(doc.getElementById('query-panel').classList.contains('is-open'), true); + app.setQueries({ One: [event(3, 'example.org', 3)], Zero: [] }); + await app.tick(3000); + rows = [...doc.querySelectorAll('#background-queries li')]; + assert.equal(rows.length, 1); + assert.match(rows[0].textContent, /\(x3\)/); + } finally { app.close(); } +}); + +test('hidden and offline pages stop both polling intervals and reject stale responses', async () => { + const app = makeBrowser(); + try { + await app.settle(); + assert.deepEqual([...app.timers.values()].map(t => t.ms).sort((a, b) => a - b), [1000, 2000, 3000]); + app.setHidden(true); + assert.equal(app.timers.size, 0); + const count = app.calls.length; + await app.tick(2000); + assert.equal(app.calls.length, count); + app.setHidden(false); + await app.settle(); + assert.equal(app.timers.size, 3); + app.setOnline(false); + assert.equal(app.timers.size, 0); + app.setOnline(true); + await app.settle(); + assert.equal(app.timers.size, 3); + } finally { app.close(); } +}); + +test('disabled query feed never requests query data', async () => { + const app = makeBrowser({ show_queries: false }); + try { + await app.settle(); + assert.equal(app.window.document.getElementById('query-panel').hidden, true); + assert.equal([...app.timers.values()].some(t => t.ms === 3000), false); + assert.equal(app.calls.some(c => c.url.startsWith('queries?')), false); + } finally { app.close(); } +}); + +test('instance names are treated as text, not executable markup', async () => { + const app = makeBrowser({ piholes: [{ name: '', enabled: true, link: false }] }); + try { + await app.settle(); + assert.equal(app.window.document.querySelectorAll('.instance-card img').length, 0); + assert.match(app.window.document.querySelector('.instance-name').textContent, / fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + +function setup(fetchImpl) { + const html = read('index.html').replace(//g, '').replace('{{ICON_URL}}', ''); + const dom = new JSDOM(html, { url: 'http://localhost:5001/', runScripts: 'outside-only' }); + const w = dom.window; + let hidden = false; + const timers = new Map(); + let next = 0; + Object.defineProperty(w.document, 'hidden', { configurable: true, get: () => hidden }); + Object.defineProperty(w.navigator, 'onLine', { configurable: true, get: () => true }); + w.setInterval = (fn, ms) => { const id = ++next; timers.set(id, { fn, ms }); return id; }; + w.clearInterval = id => timers.delete(id); + w.fetch = fetchImpl; + w.eval(read('js/dashboard-core.js')); + w.eval(read('js/app.js')); + const settle = () => new Promise(resolve => w.setTimeout(resolve, 0)); + return { + w, dom, timers, settle, + hide(value) { hidden = value; w.document.dispatchEvent(new w.Event('visibilitychange')); }, + close() { dom.window.close(); }, + }; +} + +const config = { refresh_interval: 2000, queries_refresh_interval: 3000, show_queries: false, show_network_summary: true, show_trends: false, piholes: [] }; +const response = data => ({ ok: true, json: async () => data }); + +test('a visible page recovers when an aborted initialization settles later', async () => { + let resolveFirst; + const calls = []; + const app = setup((url, options) => { + calls.push({ url, signal: options.signal }); + if (calls.length === 1) return new Promise(resolve => { resolveFirst = resolve; }); + return Promise.resolve(response({ config, data: {}, summary: { instances: 0, contributing_instances: 0, partial: false } })); + }); + try { + await app.settle(); + assert.equal(calls.length, 1); + app.hide(true); + assert.equal(calls[0].signal.aborted, true); + app.hide(false); + await app.settle(); + assert.ok(calls.length >= 2); + resolveFirst(response({ config, data: {}, summary: { instances: 0 } })); + await app.settle(); + assert.equal(app.w.document.querySelectorAll('.empty-message').length, 1); + assert.deepEqual([...app.timers.values()].map(t => t.ms).sort((a, b) => a - b), [1000, 2000]); + } finally { app.close(); } +}); + +test('a stale in-flight stats response cannot overwrite a newer foreground snapshot', async () => { + let resolveOld; + let statsCalls = 0; + const data = total => ({ One: { queries: { total, blocked: 0, frequency: 1 }, clients: { active: 1 }, gravity: { domains_being_blocked: 1 }, _pi_dash: { blocking: true } } }); + const cfg = { ...config, piholes: [{ name: 'One', enabled: true, link: false }] }; + const summary = { instances: 1, contributing_instances: 1, total_queries: 1, blocked_queries: 0, partial: false }; + const app = setup(url => { + if (url === 'init') return Promise.resolve(response({ config: cfg, data: data(1), summary })); + if (url.startsWith('data?')) { + statsCalls++; + if (statsCalls === 1) return new Promise(resolve => { resolveOld = resolve; }); + return Promise.resolve(response({ stats: data(3), summary: { ...summary, total_queries: 3 } })); + } + throw new Error('Unexpected request'); + }); + try { + await app.settle(); + const oldTick = [...app.timers.values()].find(t => t.ms === 2000).fn(); + await app.settle(); + app.hide(true); + app.hide(false); + await app.settle(); + assert.equal(app.w.document.querySelector('.instance-details [data-value="total"]').textContent, '3'); + resolveOld(response({ stats: data(2), summary: { ...summary, total_queries: 2 } })); + await oldTick; + await app.settle(); + 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 new file mode 100644 index 0000000..c16a9f0 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,282 @@ +import json +import os +import unittest +from concurrent.futures import ThreadPoolExecutor +from threading import Event +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_old_query_interval_inherits_refresh(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_without_optional_flags(self): + proxy.config = {'piholes': []} + self.assertEqual(proxy.get_refresh_interval(), 5000) + self.assertEqual(proxy.get_queries_refresh_interval(), 5000) + self.assertFalse(proxy.filtered_config()['show_queries']) + self.assertTrue(proxy.filtered_config()['show_network_summary']) + self.assertTrue(proxy.filtered_config()['show_trends']) + + def test_new_query_interval_overrides_old(self): + proxy.config = {'refresh_interval': 2000, 'queries_refresh_interval': 3000, 'piholes': []} + self.assertEqual(proxy.get_queries_refresh_interval(), 3000) + + def test_default_cache_and_explicit_disable(self): + proxy.config = {'refresh_interval': 1000, 'queries_refresh_interval': 3000, 'piholes': []} + self.assertEqual(proxy.get_cache_ttl(), 500) + proxy.config['cache_ttl'] = 0 + self.assertEqual(proxy.get_cache_ttl(), 0) + + def test_zero_enabled_piholes(self): + proxy.config = {'piholes': [{'name': 'Off', 'enabled': False}]} + self.assertEqual(proxy._stats_uncached(), {}) + self.assertEqual(proxy._queries_uncached(50), {}) + + def test_environment_secret_and_tls_compatibility(self): + with patch.dict(os.environ, {'PI_DASH_TEST_SECRET': 'resolved'}): + self.assertEqual(proxy.resolve_secret('${PI_DASH_TEST_SECRET}'), 'resolved') + self.assertEqual(proxy.resolve_secret('plain'), 'plain') + self.assertEqual(proxy.get_verify_setting({'verify_ssl': '${PI_DASH_TEST_SECRET}'}), 'resolved') + self.assertFalse(proxy.get_verify_setting({})) + self.assertTrue(proxy.get_verify_setting({'verify_ssl': True})) + + 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.assertNotIn('_comment', example) + self.assertNotIn('_comment', example['piholes'][0]) + # Older configurations may still contain explanatory fields. + example['_comment'] = 'legacy documentation' + example['piholes'][0]['_comment'] = 'legacy instance documentation' + proxy.config = example + filtered = proxy.filtered_config() + self.assertNotIn('_comment', filtered) + self.assertTrue(filtered['piholes']) + self.assertNotIn('password', filtered['piholes'][0]) + self.assertNotIn('_comment', filtered['piholes'][0]) + + +class BlockingAndSummaryTests(unittest.TestCase): + def test_blocking_states(self): + self.assertTrue(proxy.normalize_blocking('enabled')) + self.assertFalse(proxy.normalize_blocking('disabled')) + self.assertTrue(proxy.normalize_blocking(True)) + self.assertFalse(proxy.normalize_blocking(False)) + self.assertIsNone(proxy.normalize_blocking('unexpected')) + + def test_summary_is_partial_and_does_not_sum_unique_values(self): + data = { + 'one': {'queries': {'total': 100, 'blocked': 10, 'cached': 60, 'forwarded': 40}, '_pi_dash': {'blocking': True}}, + 'two': {'queries': {'total': 50, 'blocked': 15, 'cached': 20, 'forwarded': 30}, '_pi_dash': {'blocking': False}}, + 'three': {'error': 'offline', '_pi_dash': {'health': 'unreachable'}}, + 'four': {'error': 'authentication failed', '_pi_dash': {'health': 'auth_error'}}, + } + 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['contributing_instances'], 2) + self.assertEqual(result['healthy_instances'], 1) + self.assertEqual(result['blocking_disabled_instances'], 1) + self.assertEqual(result['auth_error_instances'], 1) + self.assertEqual(result['offline_instances'], 1) + self.assertTrue(result['partial']) + self.assertNotIn('clients', result) + self.assertNotIn('unique_domains', result) + self.assertNotIn('slow_instances', result) + + def test_unknown_blocking_is_not_reported_healthy(self): + result = proxy.network_summary({'one': {'queries': {'total': 0}, '_pi_dash': {'blocking': None}}}) + self.assertEqual(result['healthy_instances'], 0) + 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): + return {'queries': {'total': 1}} + with patch.object(proxy, 'pihole_get', return_value=Response()), patch.object(proxy, 'get_blocking', return_value=True): + data = proxy.fetch_one({'name': 'One', 'address': 'http://localhost'})[1] + self.assertEqual(data['_pi_dash']['health'], 'healthy') + self.assertNotIn('latency_ms', data['_pi_dash']) + + +class BlockingCacheTests(unittest.TestCase): + class Response: + def json(self): + return {'blocking': 'enabled'} + + def setUp(self): + proxy._blocking_cache.clear() + proxy._blocking_locks.clear() + + def tearDown(self): + proxy._blocking_cache.clear() + proxy._blocking_locks.clear() + + def test_distinct_piholes_check_blocking_in_parallel(self): + entered = {'One': Event(), 'Two': Event()} + release = Event() + + def slow_get(pihole, path): + self.assertEqual(path, '/api/dns/blocking') + entered[pihole['name']].set() + release.wait(3) + return self.Response() + + with patch.object(proxy, 'pihole_get', side_effect=slow_get): + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(proxy.get_blocking, {'name': name, 'address': 'http://localhost'}) + for name in ('One', 'Two') + ] + both_entered = entered['One'].wait(2) and entered['Two'].wait(2) + release.set() + values = [future.result(timeout=3) for future in futures] + + self.assertTrue(both_entered) + self.assertEqual(values, [True, True]) + + def test_same_pihole_coalesces_simultaneous_blocking_checks(self): + entered = Event() + release = Event() + + def slow_get(_pihole, _path): + entered.set() + release.wait(3) + return self.Response() + + pihole = {'name': 'One', 'address': 'http://localhost'} + with patch.object(proxy, 'pihole_get', side_effect=slow_get) as fetch: + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(proxy.get_blocking, pihole) + self.assertTrue(entered.wait(2)) + second = pool.submit(proxy.get_blocking, pihole) + release.set() + self.assertTrue(first.result(timeout=3)) + self.assertTrue(second.result(timeout=3)) + + self.assertEqual(fetch.call_count, 1) + + +class CacheTests(unittest.TestCase): + def setUp(self): + self.original = proxy.config + proxy.config = {'piholes': [], 'cache_ttl': 1000} + proxy._stats_cache.update(time=0.0, data=None) + proxy._queries_cache.clear() + + def tearDown(self): + proxy.config = self.original + proxy._stats_cache.update(time=0.0, data=None) + proxy._queries_cache.clear() + + def test_stats_cache_reuses_snapshot(self): + with patch.object(proxy, '_stats_uncached', return_value={'one': 1}) as fetch: + self.assertEqual(proxy.fetch_stats(), {'one': 1}) + self.assertEqual(proxy.fetch_stats(), {'one': 1}) + self.assertEqual(fetch.call_count, 1) + + def test_query_cache_reuses_snapshot(self): + with patch.object(proxy, '_queries_uncached', return_value={'one': []}) as fetch: + proxy.fetch_queries(50) + proxy.fetch_queries(50) + self.assertEqual(fetch.call_count, 1) + + def test_simultaneous_stats_requests_share_one_fetch(self): + entered = Event() + release = Event() + def slow_fetch(): + entered.set() + release.wait(3) + return {'one': 1} + with patch.object(proxy, '_stats_uncached', side_effect=slow_fetch) as fetch: + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(proxy.fetch_stats) + self.assertTrue(entered.wait(3)) + second = pool.submit(proxy.fetch_stats) + release.set() + self.assertEqual(first.result(timeout=3), second.result(timeout=3)) + self.assertEqual(fetch.call_count, 1) + + +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): + self.assertEqual(client.get('/data').get_json(), sample) + + def test_rich_data_shape_is_opt_in(self): + sample = {'Primary': {'queries': {'total': 10, 'blocked': 2}, '_pi_dash': {'blocking': True}}} + with proxy.app.test_client() as client, patch.object(proxy, 'fetch_stats', return_value=sample): + payload = client.get('/data?include_summary=true').get_json() + self.assertEqual(payload['stats'], sample) + self.assertIn('summary', payload) + + def test_query_length_defaults_on_invalid_input(self): + with proxy.app.test_client() as client, \ + patch.object(proxy, 'fetch_stats', return_value={}), \ + patch.object(proxy, 'fetch_queries', return_value={}) as fetch: + self.assertEqual(client.get('/queries?length=invalid').status_code, 200) + fetch.assert_called_once_with(50) + + with proxy.app.test_client() as client, \ + patch.object(proxy, 'fetch_stats', return_value={}), \ + patch.object(proxy, 'fetch_queries', return_value={}) as fetch: + self.assertEqual(client.get('/data?include_queries=true&length=invalid').status_code, 200) + fetch.assert_called_once_with(50) + + def test_query_length_is_clamped(self): + cases = (('/queries?length=0', 1), ('/queries?length=999', 200)) + for url, expected in cases: + with self.subTest(url=url), proxy.app.test_client() as client, \ + patch.object(proxy, 'fetch_queries', return_value={}) as fetch: + self.assertEqual(client.get(url).status_code, 200) + fetch.assert_called_once_with(expected) + + def test_health_does_not_contact_piholes(self): + with proxy.app.test_client() as client, patch.object(proxy, 'fetch_stats') as fetch: + self.assertEqual(client.get('/health').status_code, 200) + fetch.assert_not_called() + + def test_disabled_queries_do_not_fetch_on_init(self): + original = proxy.config + try: + proxy.config = {'piholes': [], 'show_queries': False} + with proxy.app.test_client() as client, patch.object(proxy, 'fetch_stats', return_value={}), patch.object(proxy, 'fetch_queries') as fetch: + self.assertEqual(client.get('/init').status_code, 200) + fetch.assert_not_called() + finally: + proxy.config = original + + +if __name__ == '__main__': + unittest.main()