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 @@
[](https://github.com/surajverma/pi-dash/actions/workflows/main.yml)
+[](https://github.com/surajverma/pi-dash/actions/workflows/test.yml)

[](https://github.com/surajverma/pi-dash/commits/main)
[](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.

-
-
+
## 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-Dash
Last updated: Never
-
+
+
+
Network Summary
+
+
+ Partial totals. One or more Pi-holes are not reporting.
+
+
+
+
+
Cached / Forwarded
-- / --
+
+
-
-
-
+
+
-
\ 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(/