From b662b2626d6a143134fe7b248a7546d27c6f3be6 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:22:43 +0200 Subject: [PATCH 01/53] =?UTF-8?q?build:=20=E2=AC=86=EF=B8=8F=20refresh=20t?= =?UTF-8?q?he=20frontend=20dependencies=20(Vite=208,=20Vitest=205,=20ESLin?= =?UTF-8?q?t=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript stays on 5.9: typescript-eslint does not support 7 yet. Drop echarts-for-react (unmaintained, imports an undeclared tslib) for a small wrapper on echarts core with only the line chart, which also halves the echarts chunk. Drop the unused react-query devtools and switch to @vitejs/plugin-react. Co-Authored-By: Claude Sonnet 5.5 --- current_tasks/frontend-good-enough.md | 29 + frontend/package-lock.json | 3812 ++++++------------- frontend/package.json | 26 +- frontend/src/components/EChart.tsx | 48 + frontend/src/components/TimeSeriesChart.tsx | 17 +- frontend/src/test/setup.tsx | 10 +- frontend/vite.config.ts | 13 +- frontend/vitest.config.ts | 2 +- 8 files changed, 1333 insertions(+), 2624 deletions(-) create mode 100644 current_tasks/frontend-good-enough.md create mode 100644 frontend/src/components/EChart.tsx diff --git a/current_tasks/frontend-good-enough.md b/current_tasks/frontend-good-enough.md new file mode 100644 index 00000000..08987ba8 --- /dev/null +++ b/current_tasks/frontend-good-enough.md @@ -0,0 +1,29 @@ +# Frontend: good enough, minimal, served by SensApp + +Branch: `frontend-good-enough` (no pull request yet). + +## Goal + +A small, solid explorer UI (metrics, series, chart) that ships with the SensApp image and works with and +without JWT authentication. Keep it minimal. + +## Decisions + +- No separate container. The Docker build gets a Node stage that builds the frontend; the image carries + the static files and SensApp serves them under `/ui/`. +- SensApp serves the UI by default (`SENSAPP_UI_ENABLED=true`). When enabled, `/` redirects to `/ui/`. + The files come from `SENSAPP_UI_DIR`; a missing directory is a warning, not a startup failure. +- Authentication: there is no login endpoint (tokens come from `sensapp generate-token`), so on a 401 or 403 + the UI asks for a token, keeps it for the browser tab only (`sessionStorage`) and sends it as a Bearer token. + +## Steps + +1. [ ] Analysis, dependency refresh +2. [ ] Serve the UI under `/ui/` from SensApp (config, redirect, Docker stage, docs) +3. [ ] Authentication in the frontend (token dialog, 401/403 handling, tests, live check against a JWT server) +4. [ ] Code quality pass (error handling in one place, smaller bundle, dead code, README) +5. [ ] Live check in a browser, then the next steps + +## Findings + +Filled in as the work goes. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46a89b38..77e1f247 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,9 +10,8 @@ "dependencies": { "@fontsource/roboto": "^5.2.10", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -20,35 +19,27 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", - "@tanstack/react-query-devtools": "^5.91.3", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.2", - "daisyui": "^5.5.19", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^6.1.1", + "daisyui": "^5.7.47", + "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "jsdom": "^28.1.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", + "jsdom": "^30.1.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.2", + "vitest": "^5.0.3" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -57,34 +48,46 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { @@ -97,23 +100,6 @@ "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -397,17 +383,28 @@ "specificity": "bin/cli.js" } }, - "node_modules/@corvu/utils": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@corvu/utils/-/utils-0.4.2.tgz", - "integrity": "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.6.11" - }, - "peerDependencies": { - "solid-js": "^1.8" + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@csstools/color-helpers": { @@ -431,9 +428,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", - "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -450,14 +447,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", - "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -472,20 +469,20 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.2", - "@csstools/css-calc": "^3.4.1" + "@csstools/css-calc": "^3.4.3" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", - "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -506,9 +503,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", - "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -550,1773 +547,696 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource/roboto": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz", + "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], + "node_modules/@hey-api/codegen-core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", + "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "c12": "3.3.4", + "color-support": "1.1.3" + }, "engines": { - "node": ">=18" + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", + "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", + "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "@jsdevtools/ono": "7.1.3", + "@types/json-schema": "7.0.15", + "js-yaml": "4.2.0" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=22.18.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "node_modules/@hey-api/openapi-ts": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", + "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/shared": "0.5.0", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "@lukeed/ms": "2.0.2", + "ansi-colors": "4.1.3", + "color-support": "1.1.3", + "commander": "15.0.0", + "get-tsconfig": "4.14.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "bin": { + "openapi-ts": "bin/run.js" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22.18.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", - "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@fontsource/roboto": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz", - "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@hey-api/codegen-core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", - "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/types": "0.1.4", - "ansi-colors": "4.1.3", - "c12": "3.3.4", - "color-support": "1.1.3" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", - "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "7.1.3", - "@types/json-schema": "7.0.15", - "js-yaml": "4.2.0" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/openapi-ts": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", - "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", - "@hey-api/shared": "0.5.0", - "@hey-api/spec-types": "0.2.0", - "@hey-api/types": "0.1.4", - "@lukeed/ms": "2.0.2", - "ansi-colors": "4.1.3", - "color-support": "1.1.3", - "commander": "15.0.0", - "get-tsconfig": "4.14.0" - }, - "bin": { - "openapi-ts": "bin/run.js" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" - } - }, - "node_modules/@hey-api/shared": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", - "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", - "@hey-api/spec-types": "0.2.0", - "@hey-api/types": "0.1.4", - "ansi-colors": "4.1.3", - "cross-spawn": "7.0.6", - "open": "11.0.0", - "semver": "7.8.4" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/spec-types": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", - "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/types": "0.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/types": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", - "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@internationalized/number": { - "version": "3.6.8", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz", - "integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@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==", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@kobalte/core": { - "version": "0.13.14", - "resolved": "https://registry.npmjs.org/@kobalte/core/-/core-0.13.14.tgz", - "integrity": "sha512-SBVB1lnvYbYt2x3t7fG0lo5fGLEbHQ6PII3NDPva8vD5e2v53On84ZVQiuIk0XI/55R4GQhden7Yem+R/XR8uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.5.1", - "@internationalized/number": "^3.2.1", - "@kobalte/utils": "^0.9.2", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/resize-observer": "^2.0.26", - "solid-presence": "^0.2.0", - "solid-prevent-scroll": "^0.1.11" - }, - "peerDependencies": { - "solid-js": "^1.9.8" - } - }, - "node_modules/@kobalte/utils": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@kobalte/utils/-/utils-0.9.2.tgz", - "integrity": "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.2.14", - "@solid-primitives/keyed": "^1.2.0", - "@solid-primitives/map": "^0.4.7", - "@solid-primitives/media": "^2.2.4", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/refs": "^1.0.5", - "@solid-primitives/utils": "^6.2.1" - }, - "peerDependencies": { - "solid-js": "^1.8.8" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", - "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", - "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", - "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", - "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", - "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", - "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", - "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", - "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", - "cpu": [ - "arm" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", - "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", - "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", - "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", - "cpu": [ - "loong64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", - "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", - "cpu": [ - "loong64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", - "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", - "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", - "cpu": [ - "ppc64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", - "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", - "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", - "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", - "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", - "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", - "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", - "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", - "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", - "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", - "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", - "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@solid-primitives/event-listener": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@solid-primitives/event-listener/-/event-listener-2.4.6.tgz", - "integrity": "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "funding": { + "url": "https://github.com/sponsors/hey-api" }, "peerDependencies": { - "solid-js": "^1.6.12" + "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" } }, - "node_modules/@solid-primitives/keyed": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@solid-primitives/keyed/-/keyed-1.5.3.tgz", - "integrity": "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA==", + "node_modules/@hey-api/shared": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", + "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", "dev": true, "license": "MIT", - "peerDependencies": { - "solid-js": "^1.6.12" + "dependencies": { + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "cross-spawn": "7.0.6", + "open": "11.0.0", + "semver": "7.8.4" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@solid-primitives/map": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@solid-primitives/map/-/map-0.4.13.tgz", - "integrity": "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==", + "node_modules/@hey-api/spec-types": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", + "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", "dev": true, "license": "MIT", "dependencies": { - "@solid-primitives/trigger": "^1.1.0" + "@hey-api/types": "0.1.4" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@solid-primitives/media": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@solid-primitives/media/-/media-2.3.6.tgz", - "integrity": "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw==", + "node_modules/@hey-api/types": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", + "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/static-store": "^0.1.4", - "@solid-primitives/utils": "^6.4.1" + "@humanfs/types": "^0.15.0" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/props": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/props/-/props-3.2.4.tgz", - "integrity": "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/refs": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/refs/-/refs-1.1.4.tgz", - "integrity": "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/resize-observer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@solid-primitives/resize-observer/-/resize-observer-2.2.0.tgz", - "integrity": "sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/static-store": "^0.1.4", - "@solid-primitives/utils": "^6.4.1" + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@solid-primitives/rootless": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/rootless/-/rootless-1.5.4.tgz", - "integrity": "sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@solid-primitives/static-store": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/static-store/-/static-store-0.1.4.tgz", - "integrity": "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==", - "dev": true, + "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==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@solid-primitives/storage": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-1.3.11.tgz", - "integrity": "sha512-PpQWR3TaTxHIJFbI9ZssYTM4Aa67g1vJIgps4TPhcXzHqqomrPAIveFC2FG7SDQoi9YQia8FVBjigELziJpfIg==", - "dev": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.2.0" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@solid-primitives/transition-group": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@solid-primitives/transition-group/-/transition-group-1.1.2.tgz", - "integrity": "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==", - "dev": true, + "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==", "license": "MIT", - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@solid-primitives/trigger": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/trigger/-/trigger-1.2.4.tgz", - "integrity": "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ==", - "dev": true, + "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==", + "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==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@solid-primitives/utils": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.4.1.tgz", - "integrity": "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==", + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", "dev": true, "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, "peerDependencies": { - "solid-js": "^1.6.12" + "keyv": "^5.6.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", - "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.28" - }, + "license": "MIT", "engines": { - "node": ">=10" - }, + "node": ">=8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.16.2", - "@swc/core-darwin-x64": "1.16.2", - "@swc/core-linux-arm-gnueabihf": "1.16.2", - "@swc/core-linux-arm64-gnu": "1.16.2", - "@swc/core-linux-arm64-musl": "1.16.2", - "@swc/core-linux-ppc64-gnu": "1.16.2", - "@swc/core-linux-s390x-gnu": "1.16.2", - "@swc/core-linux-x64-gnu": "1.16.2", - "@swc/core-linux-x64-musl": "1.16.2", - "@swc/core-win32-arm64-msvc": "1.16.2", - "@swc/core-win32-ia32-msvc": "1.16.2", - "@swc/core-win32-x64-msvc": "1.16.2" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", - "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", - "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", - "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", "cpu": [ "arm" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", - "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", - "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", - "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", - "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", - "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", - "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", - "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "win32" + "openharmony" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", - "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", "cpu": [ - "ia32" + "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", - "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/types": { - "version": "0.1.28", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", - "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" }, "node_modules/@tailwindcss/node": { "version": "4.3.3", @@ -2587,89 +1507,29 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tanstack/match-sorter-utils": { - "version": "8.19.4", - "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", - "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-accents": "0.5.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/query-core": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz", - "integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-devtools": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.103.2.tgz", - "integrity": "sha512-QW+uVtT7dZcc9KMmKQ846Yw25h7WPhM1cfCYYaSkkmPqkK39g+Cj1tcAt+Ege+6rBVFQvNyYln0FgAAHcYGSdA==", - "dev": true, + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", "license": "MIT", - "dependencies": { - "@kobalte/core": "^0.13.4", - "@solid-primitives/keyed": "^1.2.2", - "@solid-primitives/resize-observer": "^2.0.26", - "@solid-primitives/storage": "^1.3.11", - "@tanstack/match-sorter-utils": "^8.19.4", - "@tanstack/query-core": "5.103.2", - "clsx": "^2.1.1", - "goober": "^2.1.16", - "solid-js": "^1.9.7", - "solid-transition-group": "^0.2.3", - "superjson": "^2.2.2" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz", - "integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.103.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tanstack/react-query-devtools": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.103.2.tgz", - "integrity": "sha512-NV2AwKeS4UoyAJFcU0CNsoABKVf3A9KMX2InuII2LPUW7z6U296mbQITURAoMwDAxC2AMFCbXlMEPCgRGveQhw==", - "dev": true, + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.103.2" + "@tanstack/query-core": "5.104.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.103.2", - "@types/react": "^18 || ^19", "react": "^18 || ^19" } }, @@ -2695,9 +1555,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2709,9 +1569,18 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -2789,10 +1658,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2803,13 +1680,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", - "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { @@ -2833,17 +1710,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", - "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2856,15 +1733,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2872,16 +1749,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", - "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -2897,14 +1774,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", - "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -2919,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", - "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2937,9 +1814,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", - "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -2954,15 +1831,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", - "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2979,9 +1856,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", - "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -2993,16 +1870,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", - "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3020,56 +1897,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", - "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3084,13 +1922,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", - "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3101,64 +1939,47 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.3.tgz", - "integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1", - "@swc/core": "^1.15.46" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3176,70 +1997,22 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -3267,16 +2040,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "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/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3316,16 +2079,14 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "peer": true, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3359,16 +2120,19 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.26", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3389,20 +2153,22 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.1.tgz", - "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3420,9 +2186,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.25", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.438", + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, @@ -3434,9 +2200,9 @@ } }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3478,20 +2244,24 @@ } } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001812", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", - "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -3510,32 +2280,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3552,36 +2305,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -3602,13 +2325,6 @@ "node": ">=22.12.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3636,19 +2352,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/copy-anything": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.1.tgz", - "integrity": "sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3675,41 +2378,15 @@ "source-map-js": "^1.2.1" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", @@ -3719,9 +2396,9 @@ "license": "MIT" }, "node_modules/daisyui": { - "version": "5.7.46", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.46.tgz", - "integrity": "sha512-z/tfVXLpLRe1ojGeokzxauHA9Ij5CGfk+83EdatQbFSVr02h5mUcp4nkgQJOw68wH1wAAPu/SY+MWbDdJG2R4w==", + "version": "5.7.47", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.47.tgz", + "integrity": "sha512-NOl4vPifelRqB3CMzoU751zc44KCDD9HdHMTTqQ30uiYVpIyK7+s94/ySDWgYqjdl1tQCaCrgGBx4KCizm+a3Q==", "dev": true, "license": "MIT", "funding": { @@ -3729,17 +2406,17 @@ } }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/debug": { @@ -3881,20 +2558,6 @@ "zrender": "6.1.0" } }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "size-sensor": "^1.0.1" - }, - "peerDependencies": { - "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "react": "^15.0.0 || >=16.0.0" - } - }, "node_modules/echarts/node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -3902,16 +2565,16 @@ "license": "0BSD" }, "node_modules/electron-to-chromium": { - "version": "1.5.439", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", - "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", - "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3941,47 +2604,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4006,45 +2628,43 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4052,7 +2672,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4087,58 +2707,60 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4211,9 +2833,9 @@ } }, "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "dev": true, "license": "MIT" }, @@ -4221,6 +2843,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4255,16 +2878,13 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -4285,17 +2905,15 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -4366,9 +2984,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -4378,30 +2996,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/goober": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", - "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/hermes-estree": { @@ -4421,45 +3032,24 @@ "hermes-estree": "0.25.1" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } + "license": "MIT" }, - "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==", + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": ">= 14" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/ignore": { @@ -4472,23 +3062,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4650,39 +3223,38 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -4690,6 +3262,16 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4703,13 +3285,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4738,13 +3313,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -5038,13 +3613,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5093,16 +3661,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -5239,19 +3810,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -5403,20 +3961,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5427,6 +3971,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/rc9": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", @@ -5533,13 +4097,6 @@ "node": ">=8" } }, - "node_modules/remove-accents": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", - "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", - "dev": true, - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5550,16 +4107,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -5570,49 +4117,37 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", - "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.5", - "@rollup/rollup-android-arm64": "4.63.5", - "@rollup/rollup-darwin-arm64": "4.63.5", - "@rollup/rollup-darwin-x64": "4.63.5", - "@rollup/rollup-freebsd-arm64": "4.63.5", - "@rollup/rollup-freebsd-x64": "4.63.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", - "@rollup/rollup-linux-arm-musleabihf": "4.63.5", - "@rollup/rollup-linux-arm64-gnu": "4.63.5", - "@rollup/rollup-linux-arm64-musl": "4.63.5", - "@rollup/rollup-linux-loong64-gnu": "4.63.5", - "@rollup/rollup-linux-loong64-musl": "4.63.5", - "@rollup/rollup-linux-ppc64-gnu": "4.63.5", - "@rollup/rollup-linux-ppc64-musl": "4.63.5", - "@rollup/rollup-linux-riscv64-gnu": "4.63.5", - "@rollup/rollup-linux-riscv64-musl": "4.63.5", - "@rollup/rollup-linux-s390x-gnu": "4.63.5", - "@rollup/rollup-linux-x64-gnu": "4.63.5", - "@rollup/rollup-linux-x64-musl": "4.63.5", - "@rollup/rollup-openbsd-x64": "4.63.5", - "@rollup/rollup-openharmony-arm64": "4.63.5", - "@rollup/rollup-win32-arm64-msvc": "4.63.5", - "@rollup/rollup-win32-ia32-msvc": "4.63.5", - "@rollup/rollup-win32-x64-gnu": "4.63.5", - "@rollup/rollup-win32-x64-msvc": "4.63.5", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, "node_modules/run-applescript": { @@ -5660,29 +4195,6 @@ "node": ">=10" } }, - "node_modules/seroval": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", - "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", - "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -5712,157 +4224,35 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/size-sensor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, - "node_modules/solid-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz", - "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.1.0", - "seroval": "~1.5.4", - "seroval-plugins": "~1.5.4" - } - }, - "node_modules/solid-presence": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/solid-presence/-/solid-presence-0.2.0.tgz", - "integrity": "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@corvu/utils": "~0.4.2" - }, - "peerDependencies": { - "solid-js": "^1.8" - } - }, - "node_modules/solid-prevent-scroll": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/solid-prevent-scroll/-/solid-prevent-scroll-0.1.11.tgz", - "integrity": "sha512-2PComVCDHaQN/5t7ogEqUYeHasritZKXZ8Sb/VLkl0Web8o9YhEwOo8LSujJEahvMlYCNyKt0zRJeHldOAeWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@corvu/utils": "~0.4.2" - }, - "peerDependencies": { - "solid-js": "^1.8" - } - }, - "node_modules/solid-transition-group": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/solid-transition-group/-/solid-transition-group-0.2.3.tgz", - "integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/refs": "^1.0.5", - "@solid-primitives/transition-group": "^1.0.2" - }, - "engines": { - "node": ">=18.0.0", - "pnpm": ">=8.6.0" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } + "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "min-indent": "^1.0.0" }, "engines": { "node": ">=8" } }, - "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": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -5883,11 +4273,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.1", @@ -5915,33 +4308,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { - "version": "7.4.15", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", - "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.15" + "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.15", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", - "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", "dev": true, "license": "MIT" }, @@ -5959,16 +4342,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/ts-api-utils": { @@ -5988,8 +4371,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, - "license": "0BSD" + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -6019,16 +4402,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", - "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6043,19 +4426,19 @@ } }, "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, @@ -6101,17 +4484,16 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6127,9 +4509,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -6142,13 +4525,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -6174,39 +4560,293 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6214,16 +4854,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -6264,17 +4904,27 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "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==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -6298,18 +4948,18 @@ } }, "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/which": { @@ -6329,20 +4979,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { diff --git a/frontend/package.json b/frontend/package.json index fb147116..fde14d50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,8 @@ "dependencies": { "@fontsource/roboto": "^5.2.10", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -26,26 +25,25 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", - "@tanstack/react-query-devtools": "^5.91.3", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.2", - "daisyui": "^5.5.19", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^6.1.1", + "daisyui": "^5.7.47", + "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "jsdom": "^28.1.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", + "jsdom": "^30.1.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.2", + "vitest": "^5.0.3" }, "overrides": { "@hey-api/json-schema-ref-parser": { diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx new file mode 100644 index 00000000..1e97f3de --- /dev/null +++ b/frontend/src/components/EChart.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts/core'; +import { LineChart } from 'echarts/charts'; +import { + DataZoomComponent, + GridComponent, + LegendComponent, + ToolboxComponent, + TooltipComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +// Only what the time series chart needs: the full echarts bundle is three times bigger. +echarts.use([ + LineChart, + DataZoomComponent, + GridComponent, + LegendComponent, + ToolboxComponent, + TooltipComponent, + CanvasRenderer, +]); + +/** Minimal echarts wrapper: one instance per mount, resized with its container. */ +export default function EChart({ option }: { option: echarts.EChartsCoreOption }) { + const container = useRef(null); + const chart = useRef(null); + + useEffect(() => { + const element = container.current; + if (!element) return; + const instance = echarts.init(element); + chart.current = instance; + const observer = new ResizeObserver(() => instance.resize()); + observer.observe(element); + return () => { + observer.disconnect(); + instance.dispose(); + chart.current = null; + }; + }, []); + + useEffect(() => { + chart.current?.setOption(option, true); + }, [option]); + + return
; +} diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index d393279b..55c0a7c1 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -1,11 +1,13 @@ -import { useMemo } from 'react'; -import ReactECharts from 'echarts-for-react'; +import { lazy, Suspense, useMemo } from 'react'; import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; import { extractErrorMessage } from '../api/clientConfig'; import type { SenMLRecord } from '../hooks/useSeriesData'; +// echarts is large: load it when the first chart is drawn. +const EChart = lazy(() => import('./EChart')); + // Color palette for multiple series const COLORS = [ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', @@ -166,12 +168,11 @@ export function TimeSeriesChart() {
)} - +
+ + + +
); } diff --git a/frontend/src/test/setup.tsx b/frontend/src/test/setup.tsx index a3baa90c..1e84b11e 100644 --- a/frontend/src/test/setup.tsx +++ b/frontend/src/test/setup.tsx @@ -1,13 +1,11 @@ import '@testing-library/jest-dom/vitest'; -// Mock echarts-for-react globally since it requires canvas in jsdom -vi.mock('echarts-for-react', () => ({ +// echarts needs a canvas, which jsdom does not have. +vi.mock('../components/EChart', () => ({ default: (props: { option: unknown }) => { - const seriesCount = Array.isArray((props.option as Record)?.series) - ? ((props.option as Record).series as unknown[]).length - : 0; + const series = (props.option as { series?: unknown[] })?.series; return ( -
+
Chart
); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fa211c79..46d40ca0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,21 +1,10 @@ import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], - build: { - rollupOptions: { - output: { - manualChunks: { - echarts: ['echarts', 'echarts-for-react'], - react: ['react', 'react-dom', 'react-router-dom'], - query: ['@tanstack/react-query'], - }, - }, - }, - }, server: { proxy: { '/api': { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 67bc81a7..fe8f8504 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], From ebeaf538159a69c981ecfbf1a7c88f986f981b54 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:27:08 +0200 Subject: [PATCH 02/53] =?UTF-8?q?feat:=20=E2=9C=A8=20serve=20the=20web=20U?= =?UTF-8?q?I=20from=20SensApp=20under=20/ui/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker build gets a Node stage and the image ships the static files in /usr/share/sensapp/ui. SENSAPP_UI_ENABLED (default true) and SENSAPP_UI_DIR control it; / redirects to /ui/ when it is served. A missing build is a warning, not a startup failure. The files are public, with a CSP, nosniff and no-cache. The frontend is built for the /ui/ base, with a real favicon and Latin-only Roboto (51 assets down to 9). Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 2 + Dockerfile | 15 ++ docs/CONFIGURATION.md | 9 + docs/FRONTEND.md | 32 ++++ docs/JWT_AUTH.md | 7 +- frontend/README.md | 79 ++------- frontend/index.html | 2 +- frontend/public/favicon.svg | 1 + frontend/public/vite.svg | 1 - frontend/src/components/Layout.tsx | 6 +- frontend/src/index.css | 6 +- frontend/src/main.tsx | 2 +- frontend/vite.config.ts | 35 ++-- settings.toml | 5 + src/config/mod.rs | 12 ++ src/http/mod.rs | 1 + src/http/server.rs | 15 +- src/http/ui.rs | 195 +++++++++++++++++++++ tests/integration/real_router.rs | 51 ++++++ tests/integration/rrdcached_integration.rs | 1 + 20 files changed, 374 insertions(+), 103 deletions(-) create mode 100644 docs/FRONTEND.md create mode 100644 frontend/public/favicon.svg delete mode 100644 frontend/public/vite.svg create mode 100644 src/http/ui.rs diff --git a/.dockerignore b/.dockerignore index 509d0190..cd975667 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,5 @@ compose.test-services.yml *.duckdb *.sqlite *.log +frontend/node_modules +frontend/dist diff --git a/Dockerfile b/Dockerfile index 27273f33..72117cd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +# The web UI is static files: build them once, on the build platform, whatever the target. +FROM --platform=$BUILDPLATFORM node:24-slim AS frontend + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + FROM rust:1.98-slim-bookworm AS chef ARG DUCKDB_DOWNLOAD_LIB="1" @@ -77,8 +88,12 @@ COPY --from=builder /app/target/release/sensapp /usr/local/bin/sensapp COPY --from=builder /out/lib/ /usr/local/lib/ RUN ldconfig +# Served under /ui/, see SENSAPP_UI_ENABLED +COPY --from=frontend /frontend/dist /usr/share/sensapp/ui + ENV SENSAPP_ENDPOINT=0.0.0.0 \ SENSAPP_PORT=3000 \ + SENSAPP_UI_DIR=/usr/share/sensapp/ui \ SENSAPP_STORAGE_CONNECTION_STRING=sqlite:///var/lib/sensapp/sensapp.db \ RUST_LOG=info diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ec613407..9a9c108a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -39,6 +39,15 @@ The Helm chart sets variables through `env:` in `values.yaml` (see the [chart RE Details on what the limits protect against are in [HTTP_LIMITS.md](HTTP_LIMITS.md). +### Web UI + +| Variable | Default | Description | +| --- | --- | --- | +| `SENSAPP_UI_ENABLED` | `true` | Serves the web UI under `/ui/` and redirects `/` to it. `false` brings back the JSON name of the instance at `/`. | +| `SENSAPP_UI_DIR` | `frontend/dist` | Directory with the built UI. The container image sets it to `/usr/share/sensapp/ui`. When it has no `index.html` (the frontend was not built) SensApp logs a warning and does not serve the UI: it still starts. | + +See [FRONTEND.md](FRONTEND.md). + ### Authentication | Variable | Default | Description | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 00000000..a27a88c1 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,32 @@ +# Web UI + +SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. + +## Serving + +- The files are served under `/ui/`, and `/` redirects there. The container image carries them (the Dockerfile builds them in its own Node stage); with `cargo run`, build them first with `npm run build` in `frontend/`. +- `SENSAPP_UI_ENABLED=false` turns it off, `SENSAPP_UI_DIR` says where the files are. See [CONFIGURATION.md](CONFIGURATION.md#web-ui). A missing build is a warning at startup, never a failure. +- The files are public. Responses carry a `Content-Security-Policy` (the page only talks to its own origin, and cannot be framed), `X-Content-Type-Options: nosniff` and `Cache-Control: no-cache`. +- The UI calls the SensApp API of its own origin: `/metrics`, `/series`, `/series/{uuid}` and `/health/ready`. + +## Authentication + +With `SENSAPP_JWT_SECRET` unset the UI works without further ado. When it is set, the API answers `401` and the UI shows a dialog asking for a token: paste the output of `sensapp generate-token --scope read`. See [JWT_AUTH.md](JWT_AUTH.md). + +- The token is kept in `sessionStorage`: it is gone when the tab closes and never shared with other tabs. +- An expired or invalid token (`401`), or a token without the `read` scope (`403`), shows the dialog again with the answer of the server. +- "Sign out" in the header forgets the token. +- A token with a sensor allow list shows only its sensors, as with any client. + +SensApp has no login endpoint: it does not know users, only signed tokens. + +## Development + +```bash +cd frontend +npm ci +npm run dev # http://localhost:5173/ui/, proxies the API to http://localhost:3000 +npm run lint && npm run typecheck && npm test && npm run build +``` + +Run SensApp on port 3000 next to it (`cargo run`). `npm run openapi-ts` regenerates `src/client` from `openapi.json` (the generated files are committed). diff --git a/docs/JWT_AUTH.md b/docs/JWT_AUTH.md index c344d2e0..1b98662e 100644 --- a/docs/JWT_AUTH.md +++ b/docs/JWT_AUTH.md @@ -74,6 +74,7 @@ curl http://localhost:3000/metrics \ |----------|--------------|-------| | `GET /` | No | — | | `GET /docs` | No | — | +| `GET /ui/*` (the web UI files) | No | — | | `GET /health/live` | No | — | | `GET /health/ready` | No | — | | `GET /prometheus/metrics` | No for service metrics; yes when `include_latest_samples=true` | `read` for latest samples | @@ -89,7 +90,7 @@ curl http://localhost:3000/metrics \ | `POST /api/v1/prometheus_remote_write` | Yes | `write` | | `POST /api/v1/admin/vacuum` | Yes | `delete` (it removes duplicate samples) | -Health checks, documentation, and Prometheus scrape endpoints are always public so that orchestration tools (Kubernetes probes, Prometheus scraper) work without tokens. +Health checks, documentation, the web UI files, and Prometheus scrape endpoints are always public so that orchestration tools (Kubernetes probes, Prometheus scraper) work without tokens. ## JWT Claims @@ -131,3 +132,7 @@ Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: - With JWT authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. - Sensor-scoped tokens cannot run the database-wide `/api/v1/admin/vacuum` operation. - The `delete` scope is never implied by `read write`. It only allows deleting, not reading. With a sensor allow list, series the token cannot access are reported as not found. See [DATA_LIFECYCLE.md](DATA_LIFECYCLE.md). + +## Web UI + +The UI (see [FRONTEND.md](FRONTEND.md)) is made of public static files. When authentication is enabled, its API calls get `401`, and it asks for a token: paste the output of `sensapp generate-token`. The UI keeps the token for the browser tab only and sends it as `Authorization: Bearer`. A `read` token is enough to explore; a token without `read` gets `403`, which the UI reports the same way. diff --git a/frontend/README.md b/frontend/README.md index 4dcad1f9..bbcbfd4a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,16 @@ -# React + TypeScript + Vite +# SensApp frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The explorer UI of SensApp: React 19, TypeScript, Vite, Tailwind with daisyUI, TanStack Query, Zustand and ECharts. The build is served by SensApp under `/ui/`. -Currently, two official plugins are available: +See [docs/FRONTEND.md](../docs/FRONTEND.md) for how it is served, how it authenticates and how to develop it. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm ci +npm run dev # http://localhost:5173/ui/, the API is proxied to http://localhost:3000 +npm run lint +npm run typecheck +npm test +npm run build # dist/, served by SensApp (SENSAPP_UI_DIR) ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +`src/client` is generated from `openapi.json` by `npm run openapi-ts`, and committed. diff --git a/frontend/index.html b/frontend/index.html index 592cc7f1..c73192c9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + SensApp diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..e1738114 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c808846f..1a8119b8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { Outlet } from 'react-router-dom'; +import { Link, Outlet } from 'react-router-dom'; import { HealthBadge } from './HealthBadge'; export function Layout() { @@ -8,12 +8,12 @@ export function Layout() {
- + SensApp - +
Sensor Data Explorer
diff --git a/frontend/src/index.css b/frontend/src/index.css index 49cd2852..e7637ded 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,7 @@ @import "tailwindcss"; -@import "@fontsource/roboto"; -@import "@fontsource/roboto/500.css"; -@import "@fontsource/roboto/700.css"; +@import "@fontsource/roboto/latin-400.css"; +@import "@fontsource/roboto/latin-500.css"; +@import "@fontsource/roboto/latin-700.css"; @plugin "daisyui" { themes: light --default, dark; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b9af088b..524a72f3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -21,7 +21,7 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 46d40ca0..10e00741 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,31 +2,22 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +// The API lives at the root of SensApp; `npm run dev` proxies it. +const SENSAPP = 'http://localhost:3000' +const API_PATHS = ['/api', '/health', '/metrics', '/series', '/docs'] + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + // SensApp serves the built files under /ui/ (src/http/ui.rs), and `npm run dev` does too. + base: '/ui/', + build: { + // echarts alone is about 580 kB, and only loaded when a chart is drawn. + chunkSizeWarningLimit: 650, + }, server: { - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/metrics': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/series': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/health': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/docs': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - }, + proxy: Object.fromEntries( + API_PATHS.map((path) => [path, { target: SENSAPP, changeOrigin: true }]), + ), }, }) diff --git a/settings.toml b/settings.toml index 8b2f5a24..4dffa329 100644 --- a/settings.toml +++ b/settings.toml @@ -18,6 +18,11 @@ storage_connection_string = "postgres://postgres:postgres@localhost:5432/sensapp # Maximum concurrent write requests, extra writes get 503 + a jittered Retry-After (default: 16, 0 = no limit) # http_max_concurrent_writes = 16 +# Web UI, served under /ui/ with / redirecting to it (default: true). It needs the built frontend +# (npm run build in frontend/); the container image ships it. See docs/FRONTEND.md. +# ui_enabled = true +# ui_dir = "frontend/dist" + # Sentry DSN for error tracking (leave commented to disable) # sentry_dsn = "https://your-dsn@your-sentry-instance/project-id" diff --git a/src/config/mod.rs b/src/config/mod.rs index f618ec85..b5798fc1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,6 +31,16 @@ pub struct SensAppConfig { #[config(env = "SENSAPP_HTTP_MAX_CONCURRENT_WRITES", default = 16)] pub http_max_concurrent_writes: usize, + /// Serve the web UI under `/ui/`, and redirect `/` to it. The UI is only the static files of + /// `frontend/`: the data stays behind the same authentication as the API. + #[config(env = "SENSAPP_UI_ENABLED", default = true)] + pub ui_enabled: bool, + + /// Directory with the built UI (`frontend/dist`). The container image sets it to where it + /// ships the files. When it has no `index.html` the UI is not served and a warning is logged. + #[config(env = "SENSAPP_UI_DIR", default = "frontend/dist")] + pub ui_dir: String, + #[config(env = "SENSAPP_MAX_INFERENCES_ROWS", default = 128)] pub max_inference_rows: usize, @@ -142,6 +152,8 @@ mod tests { assert_eq!(config.port, 3000); assert_eq!(config.endpoint, IpAddr::from([127, 0, 0, 1])); + assert!(config.ui_enabled); + assert_eq!(config.ui_dir, "frontend/dist"); temp_env::with_var("SENSAPP_PORT", Some("8080"), || { let config = SensAppConfig::load().unwrap(); diff --git a/src/http/mod.rs b/src/http/mod.rs index d0239e8a..c4ffbd01 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -12,3 +12,4 @@ pub mod prometheus_write; pub mod server; pub mod simple_promql; pub mod state; +pub mod ui; diff --git a/src/http/server.rs b/src/http/server.rs index cd35362a..d3194758 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -11,6 +11,7 @@ use super::prometheus_read::prometheus_remote_read; use super::prometheus_write::publish_prometheus; use super::simple_promql::simple_promql_query; use super::state::HttpServerState; +use super::ui::{add_ui, ui_directory}; use crate::config; use crate::http::crud::{ __path_delete_series, __path_delete_series_samples, __path_get_series_availability, @@ -43,6 +44,7 @@ use futures::TryStreamExt; use serde::Serialize; use std::io; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tower::ServiceBuilder; @@ -79,6 +81,8 @@ pub struct RouterSettings { pub maintenance_timeout: Duration, /// Write requests handled at the same time, 0 for no limit pub max_concurrent_writes: usize, + /// Directory of the built web UI to serve under `/ui/`, `None` to not serve it + pub ui_dir: Option, } impl RouterSettings { @@ -88,6 +92,7 @@ impl RouterSettings { request_timeout: Duration::from_secs(config.http_server_timeout_seconds), maintenance_timeout: Duration::from_secs(config.http_maintenance_timeout_seconds), max_concurrent_writes: config.http_max_concurrent_writes, + ui_dir: ui_directory(config), }) } } @@ -148,13 +153,17 @@ pub fn build_router(state: HttpServerState, settings: &RouterSettings) -> Router // Create our application with route groups split by auth requirements. // - // Public routes — always accessible (health checks, docs, prometheus scrape): + // Public routes — always accessible (health checks, docs, prometheus scrape, the UI files): let public_routes = Router::new() - .route("/", get(frontpage)) .merge(Scalar::with_url("/docs", ApiDoc::openapi())) .route("/prometheus/metrics", get(prometheus_metrics)) .route("/health/live", get(liveness)) .route("/health/ready", get(readiness)); + // `/` redirects to the UI when it is served, and is the name of the instance otherwise + let public_routes = match &settings.ui_dir { + Some(directory) => add_ui(public_routes, directory), + None => public_routes.route("/", get(frontpage)), + }; // Read-protected routes — require a valid JWT with "read" scope when auth is enabled: let read_routes = Router::new() @@ -323,7 +332,7 @@ async fn enforce_request_body_limit( path = "/", tag = "SensApp", responses( - (status = 200, description = "SensApp Frontpage", body = String) + (status = 200, description = "SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served", body = String) ) )] async fn frontpage(State(state): State) -> Result, AppError> { diff --git a/src/http/ui.rs b/src/http/ui.rs new file mode 100644 index 00000000..fcee5614 --- /dev/null +++ b/src/http/ui.rs @@ -0,0 +1,195 @@ +//! The web UI: the static files built from `frontend/`, served under `/ui/`. +//! +//! The files are public, like `/docs`: they hold no data. The API calls the UI makes are protected +//! by the same JWT authentication as any other client's. + +use crate::config::SensAppConfig; +use axum::Router; +use axum::http::{HeaderValue, header}; +use axum::response::Redirect; +use axum::routing::get; +use std::path::{Path, PathBuf}; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; + +/// Where the UI lives. The frontend is built for this base path (`base` in `vite.config.ts`). +pub const UI_PATH: &str = "/ui"; + +/// The UI holds a token in the browser, so it only talks to its own origin, and nothing may frame it. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"; + +/// The directory to serve the UI from, or `None` when the UI is turned off or was not built. +pub fn ui_directory(config: &SensAppConfig) -> Option { + if !config.ui_enabled { + return None; + } + let directory = PathBuf::from(&config.ui_dir); + if directory.join("index.html").is_file() { + tracing::info!(directory = %directory.display(), "Serving the web UI at {UI_PATH}/"); + Some(directory) + } else { + tracing::warn!( + directory = %directory.display(), + "The web UI is enabled but {}/index.html does not exist: not serving it. \ + Build it (`npm run build` in frontend/), set SENSAPP_UI_DIR, or set SENSAPP_UI_ENABLED=false", + directory.display() + ); + None + } +} + +/// Serve the UI from `directory` under `/ui/`, and redirect `/` to it. Paths that are no file go to +/// `index.html`: the UI routes in the browser. +pub fn add_ui(router: Router, directory: &Path) -> Router +where + S: Clone + Send + Sync + 'static, +{ + let files = ServeDir::new(directory).fallback(ServeFile::new(directory.join("index.html"))); + let files = tower::ServiceBuilder::new() + // Revalidate always: a new version must not be served with the assets of the old one + .layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CONTENT_SECURITY_POLICY), + )) + .layer(SetResponseHeaderLayer::overriding( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .service(files); + router + .route("/", get(|| async { Redirect::temporary("/ui/") })) + .nest_service(UI_PATH, files) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + /// A directory that looks like a built frontend, removed on drop. + struct Dist(PathBuf); + + impl Dist { + fn new() -> Self { + let directory = + std::env::temp_dir().join(format!("sensapp-ui-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(directory.join("assets")).unwrap(); + std::fs::write(directory.join("index.html"), "SensApp UI").unwrap(); + std::fs::write(directory.join("assets/app.js"), "console.log('ui')").unwrap(); + Self(directory) + } + } + + impl Drop for Dist { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + async fn get_path(router: &Router, path: &str) -> (StatusCode, axum::http::HeaderMap, String) { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let (parts, body) = response.into_parts(); + let body = to_bytes(body, 1024 * 1024).await.unwrap(); + ( + parts.status, + parts.headers, + String::from_utf8_lossy(&body).into(), + ) + } + + #[tokio::test] + async fn serves_the_files_the_index_and_the_routes_of_the_ui() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + + for path in [ + "/ui", + "/ui/", + "/ui/index.html", + "/ui/some/route", + "/ui/series/abc", + ] { + let (status, _, body) = get_path(&router, path).await; + assert_eq!(status, StatusCode::OK, "{path}"); + assert_eq!(body, "SensApp UI", "{path}"); + } + + let (status, headers, body) = get_path(&router, "/ui/assets/app.js").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "console.log('ui')"); + assert!( + headers["content-type"] + .to_str() + .unwrap() + .contains("javascript") + ); + } + + #[tokio::test] + async fn redirects_the_root_to_the_ui() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + let (status, headers, _) = get_path(&router, "/").await; + assert_eq!(status, StatusCode::TEMPORARY_REDIRECT); + assert_eq!(headers["location"], "/ui/"); + } + + #[tokio::test] + async fn protects_the_browser_that_holds_a_token() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + for path in ["/ui/", "/ui/assets/app.js"] { + let (_, headers, _) = get_path(&router, path).await; + assert!( + headers["content-security-policy"] + .to_str() + .unwrap() + .contains("default-src 'self'") + ); + assert_eq!(headers["x-content-type-options"], "nosniff"); + assert_eq!(headers["cache-control"], "no-cache"); + } + } + + #[tokio::test] + async fn leaves_the_other_routes_alone() { + let dist = Dist::new(); + let router = add_ui( + Router::new().route("/metrics", get(|| async { "api" })), + &dist.0, + ); + let (_, headers, body) = get_path(&router, "/metrics").await; + assert_eq!(body, "api"); + assert!(!headers.contains_key("content-security-policy")); + let (status, _, _) = get_path(&router, "/other").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[test] + fn serves_only_a_built_and_enabled_ui() { + let dist = Dist::new(); + let config = |enabled: &str, directory: &str| { + temp_env::with_vars( + [ + ("SENSAPP_UI_ENABLED", Some(enabled)), + ("SENSAPP_UI_DIR", Some(directory)), + ], + || SensAppConfig::load().unwrap(), + ) + }; + let built = dist.0.to_str().unwrap(); + assert_eq!(ui_directory(&config("true", built)), Some(dist.0.clone())); + assert_eq!(ui_directory(&config("false", built)), None); + assert_eq!(ui_directory(&config("true", "/no/such/directory")), None); + } +} diff --git a/tests/integration/real_router.rs b/tests/integration/real_router.rs index aa03b973..db9aebbd 100644 --- a/tests/integration/real_router.rs +++ b/tests/integration/real_router.rs @@ -123,6 +123,7 @@ fn settings() -> RouterSettings { request_timeout: Duration::from_secs(30), maintenance_timeout: Duration::from_secs(3600), max_concurrent_writes: 16, + ui_dir: None, } } @@ -611,3 +612,53 @@ async fn the_vacuum_has_a_timeout_of_its_own() -> Result<()> { assert_eq!(status, StatusCode::GATEWAY_TIMEOUT); Ok(()) } + +/// The UI files are public, they hold no data. Its API calls need the token as any other client. +#[tokio::test] +#[serial] +async fn the_ui_is_public_and_its_data_is_not() -> Result<()> { + let dist = std::env::temp_dir().join(format!("sensapp-ui-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dist)?; + std::fs::write(dist.join("index.html"), "SensApp UI")?; + let settings = RouterSettings { + ui_dir: Some(dist.clone()), + ..settings() + }; + let auth = AuthConfig::from_secret(SECRET)?; + let (_db, router) = router(Duration::ZERO, Some(auth), settings).await?; + + let (status, headers, _) = send(&router, get_request("/")).await; + assert_eq!(status, StatusCode::TEMPORARY_REDIRECT); + assert_eq!(headers["location"], "/ui/"); + + let (status, _, body) = send(&router, get_request("/ui/")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "SensApp UI"); + + // The catalog behind it answers 401 without a token, which is what makes the UI ask for one + let (status, _, body) = send(&router, get_request("/metrics")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(body.contains("Authorization"), "{body}"); + let request = Request::builder() + .uri("/metrics") + .header("authorization", format!("Bearer {}", token("read"))) + .body(Body::empty())?; + let (status, _, _) = send(&router, request).await; + assert_eq!(status, StatusCode::OK); + + std::fs::remove_dir_all(dist)?; + Ok(()) +} + +/// Without the UI, `/` is the name of the instance as before. +#[tokio::test] +#[serial] +async fn the_root_is_the_name_of_the_instance_without_the_ui() -> Result<()> { + let (_db, router) = router(Duration::ZERO, None, settings()).await?; + let (status, _, body) = send(&router, get_request("/")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "\"SensApp Test\""); + let (status, _, _) = send(&router, get_request("/ui/")).await; + assert_eq!(status, StatusCode::NOT_FOUND); + Ok(()) +} diff --git a/tests/integration/rrdcached_integration.rs b/tests/integration/rrdcached_integration.rs index d40bf03f..e13db519 100644 --- a/tests/integration/rrdcached_integration.rs +++ b/tests/integration/rrdcached_integration.rs @@ -907,6 +907,7 @@ mod rrdcached_tests { request_timeout: Duration::from_secs(30), maintenance_timeout: Duration::from_secs(3600), max_concurrent_writes: 16, + ui_dir: None, }, ) } From 62a8b13aba90a962b7719d3e6f2dcf885c2ee4f5 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:33:50 +0200 Subject: [PATCH 03/53] =?UTF-8?q?feat:=20=E2=9C=A8=20ask=20for=20a=20token?= =?UTF-8?q?=20in=20the=20UI=20when=20the=20API=20answers=20401=20or=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI has no login: tokens come from sensapp generate-token. On a 401 (missing, invalid or expired token) or a 403 (a token without the read scope) a dialog shows the answer of the server and asks for a JWT. The token is sent as a Bearer token, kept in sessionStorage only, and the refused queries are asked again. The header shows who is signed in, with Sign out, which forgets the token and the data. One unwrap() turns the generated client's returned errors into ApiError with the status, instead of four copies of the same check. Authentication errors are not retried. Checked against a live SensApp with JWT: no token, invalid token, write-only token (403), read token, sign out. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/api/clientConfig.test.ts | 76 ++++++++- frontend/src/api/clientConfig.ts | 42 ++++- frontend/src/api/queryClient.ts | 24 +++ frontend/src/auth.test.tsx | 169 ++++++++++++++++++++ frontend/src/components/AuthDialog.tsx | 84 ++++++++++ frontend/src/components/AuthStatus.tsx | 41 +++++ frontend/src/components/Layout.tsx | 4 + frontend/src/components/TimeSeriesChart.tsx | 7 +- frontend/src/hooks/useMetrics.ts | 7 +- frontend/src/hooks/useSeries.ts | 7 +- frontend/src/hooks/useSeriesData.ts | 7 +- frontend/src/main.tsx | 12 +- frontend/src/stores/useAuthStore.test.ts | 83 ++++++++++ frontend/src/stores/useAuthStore.ts | 79 +++++++++ frontend/src/test/fakeToken.ts | 11 ++ 15 files changed, 620 insertions(+), 33 deletions(-) create mode 100644 frontend/src/api/queryClient.ts create mode 100644 frontend/src/auth.test.tsx create mode 100644 frontend/src/components/AuthDialog.tsx create mode 100644 frontend/src/components/AuthStatus.tsx create mode 100644 frontend/src/stores/useAuthStore.test.ts create mode 100644 frontend/src/stores/useAuthStore.ts create mode 100644 frontend/src/test/fakeToken.ts diff --git a/frontend/src/api/clientConfig.test.ts b/frontend/src/api/clientConfig.test.ts index d5853b9c..d4f036eb 100644 --- a/frontend/src/api/clientConfig.test.ts +++ b/frontend/src/api/clientConfig.test.ts @@ -1,5 +1,77 @@ -import { describe, it, expect } from 'vitest'; -import { extractErrorMessage } from './clientConfig'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { client } from '../client/client.gen'; +import { listMetrics } from '../client'; +import { ApiError, configureApiClient, extractErrorMessage, unwrap } from './clientConfig'; +import { useAuthStore } from '../stores/useAuthStore'; + +describe('ApiError and unwrap', () => { + it('returns the data of a successful call', () => { + expect(unwrap({ data: { ok: true } })).toEqual({ ok: true }); + }); + + it('throws an ApiError with the status and the message of the server', () => { + try { + unwrap({ error: 'Invalid token: ExpiredSignature', response: { status: 401 } }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toBe('Invalid token: ExpiredSignature'); + expect((error as ApiError).status).toBe(401); + expect((error as ApiError).isAuthError).toBe(true); + } + }); + + it('knows which errors are about authentication', () => { + expect(new ApiError('no', 401).isAuthError).toBe(true); + expect(new ApiError('no', 403).isAuthError).toBe(true); + expect(new ApiError('no', 400).isAuthError).toBe(false); + expect(new ApiError('no', 500).isAuthError).toBe(false); + expect(new ApiError('no').isAuthError).toBe(false); + }); +}); + +describe('the configured API client', () => { + let authorization: string | null | undefined; + let status = 200; + + beforeAll(() => { + configureApiClient(); + client.setConfig({ + baseUrl: 'http://example.test', + fetch: async (input) => { + authorization = (input as Request).headers.get('Authorization'); + return status === 200 + ? new Response('{}', { headers: { 'Content-Type': 'application/json' } }) + : new Response('Invalid token: ExpiredSignature', { status }); + }, + }); + }); + + beforeEach(() => { + authorization = undefined; + status = 200; + useAuthStore.setState({ token: null }); + }); + + it('sends no Authorization header without a token', async () => { + await listMetrics(); + expect(authorization).toBeNull(); + }); + + it('sends the token as a Bearer token', async () => { + useAuthStore.getState().signIn('the.jwt.token'); + await listMetrics(); + expect(authorization).toBe('Bearer the.jwt.token'); + }); + + it('turns a refused request into an authentication error', async () => { + status = 401; + const result = await listMetrics(); + expect(() => unwrap(result)).toThrow( + expect.objectContaining({ status: 401, message: 'Invalid token: ExpiredSignature' }), + ); + }); +}); describe('extractErrorMessage', () => { it('extracts message from Error objects', () => { diff --git a/frontend/src/api/clientConfig.ts b/frontend/src/api/clientConfig.ts index b15dbd69..225df495 100644 --- a/frontend/src/api/clientConfig.ts +++ b/frontend/src/api/clientConfig.ts @@ -1,14 +1,52 @@ import { client } from '../client/client.gen'; +import { useAuthStore } from '../stores/useAuthStore'; export function configureApiClient() { const baseUrl = import.meta.env.VITE_SENSAPP_API_URL || ''; client.setConfig({ baseUrl }); + client.interceptors.request.use((request) => { + const { token } = useAuthStore.getState(); + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + return request; + }); +} + +/** An error answer of the API. 401 and 403 mean the token is missing, wrong or not enough. */ +export class ApiError extends Error { + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } + + get isAuthError(): boolean { + return this.status === 401 || this.status === 403; + } +} + +/** + * The data of a generated client call, or an `ApiError` carrying the status and the message of + * the server. The client returns errors instead of throwing them; React Query needs a throw. + */ +export function unwrap(result: { + data?: T; + error?: unknown; + response?: { status: number }; +}): T { + if (result.error) { + throw new ApiError(extractErrorMessage(result.error), result.response?.status); + } + return result.data as T; } /** * Extract a human-readable error message from API errors. - * hey-api/client-fetch throws raw response data on throwOnError, - * which may be an object, string, or Error. + * hey-api/client-fetch returns the body of an error answer: parsed JSON, or the plain text + * (which is what 401 and 403 are), or `{}` for an empty body. */ export function extractErrorMessage(error: unknown): string { if (error instanceof Error) { diff --git a/frontend/src/api/queryClient.ts b/frontend/src/api/queryClient.ts new file mode 100644 index 00000000..261c0be1 --- /dev/null +++ b/frontend/src/api/queryClient.ts @@ -0,0 +1,24 @@ +import { QueryCache, QueryClient } from '@tanstack/react-query'; +import { ApiError } from './clientConfig'; +import { useAuthStore } from '../stores/useAuthStore'; + +export function createQueryClient() { + return new QueryClient({ + // The server refused the request: ask for a token instead of showing an error alone + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiError && error.isAuthError) { + useAuthStore.getState().reject(error.message); + } + }, + }), + defaultOptions: { + queries: { + staleTime: 30_000, + // Asking again does not make a token valid + retry: (failureCount, error) => + !(error instanceof ApiError && error.isAuthError) && failureCount < 1, + }, + }, + }); +} diff --git a/frontend/src/auth.test.tsx b/frontend/src/auth.test.tsx new file mode 100644 index 00000000..502974a9 --- /dev/null +++ b/frontend/src/auth.test.tsx @@ -0,0 +1,169 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import App from './App'; +import { createQueryClient } from './api/queryClient'; +import { useAuthStore } from './stores/useAuthStore'; +import { useSelectionStore } from './stores/useSelectionStore'; +import { fakeToken } from './test/fakeToken'; + +// A server that wants a token: the generated client answers like it does on an HTTP error, with +// the JSON body SensApp sends, {"error": "…"} +const mockListMetrics = vi.fn(); +vi.mock('./client', () => ({ + listMetrics: (...args: unknown[]) => mockListMetrics(...args), + listSeries: vi.fn().mockResolvedValue({ data: { 'dcat:dataset': [] } }), + getSeriesData: vi.fn().mockResolvedValue({ data: [] }), + readiness: vi.fn().mockResolvedValue({ data: { status: 'ready', database: 'sqlite' } }), +})); + +const GOOD_TOKEN = fakeToken({ sub: 'alice', scope: 'read', exp: 4_102_444_800 }); + +const catalog = { + '@type': 'dcat:Catalog', + 'dcat:dataset': [ + { + '@type': 'dcat:Dataset', + '@id': 'temperature', + 'dct:identifier': 'metric:temperature', + 'dct:title': 'temperature', + 'sensor:type': 'float', + 'sensor:seriesCount': 2, + 'dcat:distribution': [], + }, + ], +}; + +let calls = 0; + +/** Answers the catalog to the good token, and what SensApp answers to anything else. */ +function sensappWithJwt(token: string | null) { + calls++; + if (token === GOOD_TOKEN) return { data: catalog }; + if (token === 'no-read-scope') { + return { error: { error: 'Read access required' }, response: { status: 403 } }; + } + return token + ? { error: { error: 'Invalid token: ExpiredSignature' }, response: { status: 401 } } + : { error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }; +} + +function renderApp() { + const queryClient = createQueryClient(); + // No pause between the asks, and no retry of a failure that is not about authentication + queryClient.setDefaultOptions({ queries: { staleTime: 30_000, retry: false } }); + const view = render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + return { ...view, queryClient }; +} + +describe('authentication', () => { + beforeEach(() => { + calls = 0; + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + useSelectionStore.setState({ selectedMetric: null, selectedSeries: [], labelFilter: '' }); + mockListMetrics.mockImplementation(() => Promise.resolve(sensappWithJwt(useAuthStore.getState().token))); + }); + + it('asks for a token when the server answers 401, then shows the data', async () => { + const user = userEvent.setup(); + renderApp(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Authentication required'); + expect(dialog).toHaveTextContent('Missing or invalid Authorization header'); + expect(dialog).toHaveTextContent('sensapp generate-token'); + expect(screen.getByRole('button', { name: 'Use token' })).toBeDisabled(); + + await user.type(screen.getByLabelText('JWT'), `Bearer ${GOOD_TOKEN}`); + await user.click(screen.getByRole('button', { name: 'Use token' })); + + // The refused request is asked again with the token + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(useAuthStore.getState().token).toBe(GOOD_TOKEN); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + it('does not ask again and again for what the server refuses', async () => { + renderApp(); + await screen.findByRole('dialog'); + // One request: a refusal is not retried + expect(calls).toBe(1); + }); + + it('says why a token was not accepted, and asks for another', async () => { + const user = userEvent.setup(); + renderApp(); + await screen.findByRole('dialog'); + + await user.type(screen.getByLabelText('JWT'), 'an.expired.token'); + await user.click(screen.getByRole('button', { name: 'Use token' })); + + const dialog = await screen.findByText('This token is not accepted'); + expect(dialog.closest('[role="dialog"]')).toHaveTextContent('Invalid token: ExpiredSignature'); + + await user.type(screen.getByLabelText('JWT'), GOOD_TOKEN); + await user.click(screen.getByRole('button', { name: 'Use token' })); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + }); + + it('asks for another token when the one it has is not enough (403)', async () => { + sessionStorage.setItem('sensapp-auth', JSON.stringify({ state: { token: 'no-read-scope' }, version: 0 })); + await useAuthStore.persist.rehydrate(); + renderApp(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Read access required'); + }); + + it('can be dismissed, and reopened with Sign in', async () => { + const user = userEvent.setup(); + renderApp(); + await screen.findByRole('dialog'); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // The error stays where the data should be + expect(await screen.findByText(/Failed to load metrics/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Sign in' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('signs out: forgets the token and the data, and asks again', async () => { + const user = userEvent.setup(); + useAuthStore.getState().signIn(GOOD_TOKEN); + const { queryClient } = renderApp(); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + expect(useAuthStore.getState().token).toBeNull(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(queryClient.getQueryData(['metrics', { name: undefined, type: undefined }])).toBeUndefined(); + expect(screen.queryByText('temperature')).not.toBeInTheDocument(); + }); + + it('works without any token when the server does not ask for one', async () => { + mockListMetrics.mockResolvedValue({ data: catalog }); + renderApp(); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AuthDialog.tsx b/frontend/src/components/AuthDialog.tsx new file mode 100644 index 00000000..d89c73cd --- /dev/null +++ b/frontend/src/components/AuthDialog.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { normalizeToken, useAuthStore } from '../stores/useAuthStore'; + +/** Asks for a JWT when the server refuses a request (401, or 403 for a token that is not enough). */ +export function AuthDialog() { + const { dialogOpen, message, token, signIn, closeDialog } = useAuthStore(); + const queryClient = useQueryClient(); + const [input, setInput] = useState(''); + + if (!dialogOpen) return null; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const candidate = normalizeToken(input); + if (!candidate) return; + signIn(candidate); + setInput(''); + // Everything that was refused is asked again with the token + void queryClient.invalidateQueries(); + } + + return ( +
event.key === 'Escape' && closeDialog()} + > +
+

+ {token ? 'This token is not accepted' : 'Authentication required'} +

+

+ This SensApp asks for a token. Paste one generated with: +

+
+          sensapp generate-token ui --scope read
+        
+ + {message && ( +
+ {message} +
+ )} + + +

+ Kept for this browser tab only, and sent to this server only. +

+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx new file mode 100644 index 00000000..61da3ef4 --- /dev/null +++ b/frontend/src/components/AuthStatus.tsx @@ -0,0 +1,41 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { describeToken, useAuthStore } from '../stores/useAuthStore'; + +/** Who is signed in, with the way out; or a way in once the server asked for a token. */ +export function AuthStatus() { + const { token, authRequired, signOut, openDialog } = useAuthStore(); + const queryClient = useQueryClient(); + + if (token) { + const info = describeToken(token); + const details = [ + info?.scope && `scope: ${info.scope}`, + info?.expires && `expires: ${info.expires.toLocaleString()}`, + ].filter(Boolean); + + function handleSignOut() { + signOut(); + // Nothing of a signed-out session stays in memory, and what is on screen asks again + void queryClient.resetQueries(); + } + + return ( +
+ {info?.subject ?? 'token'} + +
+ ); + } + + if (authRequired) { + return ( + + ); + } + + return null; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 1a8119b8..2d04e21e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,6 @@ import { Link, Outlet } from 'react-router-dom'; +import { AuthDialog } from './AuthDialog'; +import { AuthStatus } from './AuthStatus'; import { HealthBadge } from './HealthBadge'; export function Layout() { @@ -20,6 +22,7 @@ export function Layout() {
+ +
); } diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 55c0a7c1..75caa041 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useMemo } from 'react'; import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; import type { SenMLRecord } from '../hooks/useSeriesData'; // echarts is large: load it when the first chart is drawn. @@ -62,11 +62,8 @@ export function TimeSeriesChart() { end: timeRange.end, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } return { - records: result.data as unknown as SenMLRecord[], + records: unwrap(result) as unknown as SenMLRecord[], series: s, colorIndex: index, }; diff --git a/frontend/src/hooks/useMetrics.ts b/frontend/src/hooks/useMetrics.ts index f49d22e7..528d2a83 100644 --- a/frontend/src/hooks/useMetrics.ts +++ b/frontend/src/hooks/useMetrics.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { listMetrics } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; export interface DcatDataset { '@type': string; @@ -49,10 +49,7 @@ export function useMetrics(filters?: { type: filters?.type, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as DcatCatalog; + return unwrap(result) as unknown as DcatCatalog; }, }); } diff --git a/frontend/src/hooks/useSeries.ts b/frontend/src/hooks/useSeries.ts index 00ef9e97..a93a7ad9 100644 --- a/frontend/src/hooks/useSeries.ts +++ b/frontend/src/hooks/useSeries.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { listSeries } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; export interface SeriesLabel { [key: string]: string; @@ -49,10 +49,7 @@ export function useSeries(filters?: { selector: filters?.selector, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as SeriesCatalog; + return unwrap(result) as unknown as SeriesCatalog; }, enabled: !!filters?.metric || !!filters?.selector, }); diff --git a/frontend/src/hooks/useSeriesData.ts b/frontend/src/hooks/useSeriesData.ts index 5c889260..ac5fd950 100644 --- a/frontend/src/hooks/useSeriesData.ts +++ b/frontend/src/hooks/useSeriesData.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { getSeriesData } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; export interface SenMLRecord { bn?: string; @@ -36,10 +36,7 @@ export function useSeriesData( limit: options?.limit, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as SenMLRecord[]; + return unwrap(result) as unknown as SenMLRecord[]; }, enabled: !!seriesUuid, }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 524a72f3..3d227451 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,22 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' import { configureApiClient } from './api/clientConfig' +import { createQueryClient } from './api/queryClient' // Configure API client configureApiClient() -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, -}) +const queryClient = createQueryClient() createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/stores/useAuthStore.test.ts b/frontend/src/stores/useAuthStore.test.ts new file mode 100644 index 00000000..661ddc54 --- /dev/null +++ b/frontend/src/stores/useAuthStore.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fakeToken } from '../test/fakeToken'; +import { describeToken, normalizeToken, useAuthStore } from './useAuthStore'; + +describe('useAuthStore', () => { + beforeEach(() => { + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + }); + + it('opens the dialog with the answer of the server when a request is refused', () => { + useAuthStore.getState().reject('Missing or invalid Authorization header'); + const state = useAuthStore.getState(); + expect(state.dialogOpen).toBe(true); + expect(state.authRequired).toBe(true); + expect(state.message).toBe('Missing or invalid Authorization header'); + }); + + it('signs in with a token, closes the dialog and forgets the complaint', () => { + useAuthStore.getState().reject('Invalid token: ExpiredSignature'); + useAuthStore.getState().signIn(' Bearer abc.def.ghi '); + const state = useAuthStore.getState(); + expect(state.token).toBe('abc.def.ghi'); + expect(state.dialogOpen).toBe(false); + expect(state.message).toBeNull(); + }); + + it('signs out but remembers that the server wants a token', () => { + useAuthStore.getState().reject('no'); + useAuthStore.getState().signIn('abc'); + useAuthStore.getState().signOut(); + expect(useAuthStore.getState().token).toBeNull(); + expect(useAuthStore.getState().authRequired).toBe(true); + }); + + it('keeps the token in sessionStorage only, and nothing else', () => { + useAuthStore.getState().reject('no'); + useAuthStore.getState().signIn('abc.def.ghi'); + expect(sessionStorage.getItem('sensapp-auth')).toContain('abc.def.ghi'); + expect(localStorage.getItem('sensapp-auth')).toBeNull(); + const stored = JSON.parse(sessionStorage.getItem('sensapp-auth')!); + expect(Object.keys(stored.state)).toEqual(['token']); + }); + + it('can be reopened and closed by hand', () => { + useAuthStore.getState().openDialog(); + expect(useAuthStore.getState().dialogOpen).toBe(true); + useAuthStore.getState().closeDialog(); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); +}); + +describe('normalizeToken', () => { + it('strips what comes with a pasted token', () => { + expect(normalizeToken('abc')).toBe('abc'); + expect(normalizeToken('Bearer abc\n')).toBe('abc'); + expect(normalizeToken('bearer abc')).toBe('abc'); + expect(normalizeToken('"abc"')).toBe('abc'); + expect(normalizeToken(' ')).toBe(''); + }); +}); + +describe('describeToken', () => { + it('reads the claims of a token', () => { + const info = describeToken(fakeToken({ sub: 'alice', scope: 'read write', exp: 4_102_444_800 })); + expect(info).toEqual({ + subject: 'alice', + scope: 'read write', + expires: new Date(4_102_444_800_000), + }); + }); + + it('reads a subject that is not ASCII', () => { + expect(describeToken(fakeToken({ sub: 'Zoë' }))?.subject).toBe('Zoë'); + }); + + it('returns null for what is not a token', () => { + expect(describeToken('')).toBeNull(); + expect(describeToken('no-dots')).toBeNull(); + expect(describeToken('a.%%%.c')).toBeNull(); + expect(describeToken('a.bm90LWpzb24.c')).toBeNull(); + }); +}); diff --git a/frontend/src/stores/useAuthStore.ts b/frontend/src/stores/useAuthStore.ts new file mode 100644 index 00000000..fb724402 --- /dev/null +++ b/frontend/src/stores/useAuthStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface AuthState { + /** The JWT sent as `Authorization: Bearer`. Kept for this browser tab only. */ + token: string | null; + /** Set when the server asked for a token, to offer signing in. */ + authRequired: boolean; + dialogOpen: boolean; + /** What the server answered to the refused request, shown in the dialog. */ + message: string | null; + signIn: (token: string) => void; + signOut: () => void; + /** The server answered 401 or 403. */ + reject: (message: string) => void; + openDialog: () => void; + closeDialog: () => void; +} + +/** What people paste: the token alone, or with the `Bearer ` prefix or quotes around it. */ +export function normalizeToken(input: string): string { + return input + .trim() + .replace(/^bearer\s+/i, '') + .replace(/^["']|["']$/g, '') + .trim(); +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + authRequired: false, + dialogOpen: false, + message: null, + + signIn: (token) => + set({ token: normalizeToken(token), dialogOpen: false, message: null }), + signOut: () => set({ token: null, message: null }), + reject: (message) => + set({ authRequired: true, dialogOpen: true, message }), + openDialog: () => set({ dialogOpen: true, message: null }), + closeDialog: () => set({ dialogOpen: false }), + }), + { + name: 'sensapp-auth', + // sessionStorage: gone when the tab closes, not shared with the other tabs + storage: createJSONStorage(() => sessionStorage), + partialize: ({ token }) => ({ token }), + }, + ), +); + +export interface TokenInfo { + subject?: string; + scope?: string; + expires?: Date; +} + +/** + * What the token says about itself, to show who is signed in. Nothing is verified here: the + * server does that, and answers 401 when the token is wrong. + */ +export function describeToken(token: string): TokenInfo | null { + try { + const payload = token.split('.')[1]; + if (!payload) return null; + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const claims = JSON.parse(new TextDecoder().decode(bytes)) as Record; + return { + subject: typeof claims.sub === 'string' ? claims.sub : undefined, + scope: typeof claims.scope === 'string' ? claims.scope : undefined, + expires: typeof claims.exp === 'number' ? new Date(claims.exp * 1000) : undefined, + }; + } catch { + return null; + } +} diff --git a/frontend/src/test/fakeToken.ts b/frontend/src/test/fakeToken.ts new file mode 100644 index 00000000..ef5afcc2 --- /dev/null +++ b/frontend/src/test/fakeToken.ts @@ -0,0 +1,11 @@ +/** A JWT with these claims, for tests. The UI does not check the signature, the server does. */ +export function fakeToken(claims: Record): string { + const encode = (value: unknown) => { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + }; + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`; +} From cda37db3858382a5cc611b14afb302fe42dfe7df Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:41:05 +0200 Subject: [PATCH 04/53] =?UTF-8?q?test:=20=E2=9C=85=20fail=20when=20fronten?= =?UTF-8?q?d/openapi.json=20is=20not=20the=20OpenAPI=20document=20of=20the?= =?UTF-8?q?=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated client had drifted: step, aggregation, limit and simplify, the availability and last-sample endpoints and the delete routes were all missing. A test compares the document with the server's and says how to update both (UPDATE_OPENAPI=1, then npm run openapi-ts). The client is regenerated. Co-Authored-By: Claude Sonnet 5.5 --- frontend/openapi.json | 1205 ++++++++++++++++- .../src/client/@tanstack/react-query.gen.ts | 150 +- frontend/src/client/index.ts | 4 +- frontend/src/client/sdk.gen.ts | 42 +- frontend/src/client/types.gen.ts | 345 ++++- src/http/server.rs | 25 + 6 files changed, 1752 insertions(+), 19 deletions(-) diff --git a/frontend/openapi.json b/frontend/openapi.json index 3707078d..1b0f071f 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -1 +1,1204 @@ -{"openapi":"3.1.0","info":{"title":"sensapp","description":"","license":{"name":""},"version":"0.3.0"},"paths":{"/":{"get":{"tags":["SensApp"],"operationId":"frontpage","responses":{"200":{"description":"SensApp Frontpage","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/v1/admin/vacuum":{"post":{"tags":["Admin"],"summary":"Database Vacuuming","description":"Cleans up and optimizes the database by removing unused data and reclaiming space.\n(only if supported by the underlying storage engine).","operationId":"vacuum_database","responses":{"200":{"description":"Database vacuum completed successfully","content":{"text/plain":{"schema":{"type":"string"}}}},"500":{"description":"Failed to vacuum database","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/v1/prometheus_remote_read":{"post":{"tags":["Prometheus"],"summary":"Prometheus Remote Read API.","description":"Allows you to read data from SensApp using Prometheus remote read protocol.\n\nIt follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/).","operationId":"prometheus_remote_read","parameters":[{"name":"content-encoding","in":"header","description":"Content encoding, must be snappy","required":true,"schema":{"type":"string","format":"snappy"}},{"name":"content-type","in":"header","description":"Content type, must be application/x-protobuf","required":true,"schema":{"type":"string","format":"application/x-protobuf"}},{"name":"x-prometheus-remote-read-version","in":"header","description":"Prometheus Remote Read version, must be 0.1.0","required":true,"schema":{"type":"string","format":"0.1.0"}}],"requestBody":{"description":"Prometheus Remote Read endpoint. [Reference](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/)","content":{"application/x-protobuf":{}}},"responses":{"200":{"description":"Read Response","content":{"application/x-protobuf":{}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/api/v1/prometheus_remote_write":{"post":{"tags":["Prometheus"],"summary":"Prometheus Remote Write API.","description":"Allows you to write data from Prometheus to SensApp.\n\nIt follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/).","operationId":"publish_prometheus","parameters":[{"name":"content-encoding","in":"header","description":"Content encoding, must be snappy","required":true,"schema":{"type":"string","format":"snappy"}},{"name":"content-type","in":"header","description":"Content type, must be application/x-protobuf","required":true,"schema":{"type":"string","format":"application/x-protobuf"}},{"name":"x-prometheus-remote-write-version","in":"header","description":"Prometheus Remote Write version, must be 0.1.0","required":true,"schema":{"type":"string","format":"0.1.0"}}],"requestBody":{"description":"Prometheus Remote Write endpoint. [Reference](https://prometheus.io/docs/concepts/remote_write_spec/)","content":{"application/x-protobuf":{}}},"responses":{"204":{"description":"No Content"},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/api/v1/query":{"get":{"tags":["SensApp"],"summary":"Simple PromQL query endpoint.","description":"Parses a PromQL expression and returns matching time series data.\nOnly simple selectors (VectorSelector and MatrixSelector) are supported.\n\n# Examples\n\n- Simple metric: `GET /api/v1/query?query=my_metric`\n- With labels: `GET /api/v1/query?query=my_metric{env=\"prod\"}`\n- Range query: `GET /api/v1/query?query=my_metric[5m]`\n- With format: `GET /api/v1/query?query=my_metric&format=csv`","operationId":"simple_promql_query","parameters":[{"name":"query","in":"query","description":"PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"Output format: senml (default), csv, jsonl, or arrow","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Query results in requested format","content":{"application/json":{"schema":{}}}},"400":{"description":"Invalid or unsupported PromQL query"},"500":{"description":"Internal server error"}}}},"/api/v2/write":{"post":{"tags":["InfluxDB"],"summary":"InfluxDB Compatible Write API.","description":"Allows you to write data from InfluxDB or Telegraf to SensApp.\n[More information.](https://github.com/SINTEF/sensapp/blob/main/docs/INFLUX_DB.md)","operationId":"publish_influxdb","parameters":[{"name":"bucket","in":"query","description":"Bucket name","required":true,"schema":{"type":"string"},"example":"sensapp"},{"name":"org","in":"query","description":"Organization name","required":false,"schema":{"type":"string"},"example":"sensapp"},{"name":"org_id","in":"query","description":"Organization ID","required":false,"schema":{"type":"string"}},{"name":"precision","in":"query","description":"Precision of the timestamps. One of ns, us, ms, s","required":false,"schema":{"type":"string"}}],"requestBody":{"description":"InfluxDB Line Protocol endpoint. [Reference](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/).","content":{"text/plain":{"schema":{"type":"string"},"example":"cpu,host=A,region=west usage_system=64.2 1590488773254420000"}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/health/live":{"get":{"tags":["Health"],"summary":"Liveness check","description":"Checks if the service is running.\nThis endpoint always returns 200 OK if the server is able to respond.","operationId":"liveness","responses":{"200":{"description":"Service is alive","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/health/ready":{"get":{"tags":["Health"],"summary":"Readiness check","description":"Checks if the service is ready to accept traffic.\nThis includes checking if the database connection is working","operationId":"readiness","responses":{"200":{"description":"Service is ready","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}}}}},"/metrics":{"get":{"tags":["SensApp"],"summary":"List unique metrics (measurement types) with aggregated information in DCAT catalog format.","operationId":"list_metrics","parameters":[{"name":"name","in":"query","description":"Filter metrics by name (substring match)","required":false,"schema":{"type":"string"}},{"name":"name_regex","in":"query","description":"Filter metrics by name using regex pattern","required":false,"schema":{"type":"string"}},{"name":"type","in":"query","description":"Filter metrics by sensor type (float, integer, string, boolean, location, json, blob, numeric)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metrics catalog in DCAT format","content":{"application/json":{"schema":{}}}}}}},"/prometheus/metrics":{"get":{"tags":["Observability"],"operationId":"prometheus_metrics","responses":{"200":{"description":"Prometheus-compatible metrics","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/publish":{"post":{"tags":["SensApp"],"summary":"SensApp native data ingestion API supporting multiple formats.","description":"Accepts sensor data in one of the following formats:\n- **SenML JSON** (RFC 8428): `Content-Type: application/json`\n- **CSV**: `Content-Type: text/csv` or `application/csv`\n- **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.file`\n\nIf no Content-Type header is provided, defaults to CSV format.","operationId":"publish_sensors_data","requestBody":{"description":"Sensor data in SenML JSON, CSV, or Apache Arrow format","content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"Data ingested successfully","content":{"text/plain":{"schema":{"type":"string"}}}},"400":{"description":"Bad Request - invalid data format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/series":{"get":{"tags":["SensApp"],"summary":"List all series (time series) in DCAT catalog format.","operationId":"list_series","parameters":[{"name":"metric","in":"query","description":"Filter series by metric name","required":false,"schema":{"type":"string"}},{"name":"selector","in":"query","description":"PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Time series catalog in DCAT format","content":{"application/json":{"schema":{}}}}}}},"/series/{series_uuid}":{"get":{"tags":["SensApp"],"summary":"Get series data in various formats based on query parameter.","operationId":"get_series_data","parameters":[{"name":"series_uuid","in":"path","description":"UUID of the series","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"Output format: senml, csv, or jsonl (default: senml)","required":false,"schema":{"type":"string"}},{"name":"start","in":"query","description":"Start datetime in ISO 8601 format (e.g., '2024-01-15T10:30:00Z')","required":false,"schema":{"type":"string"}},{"name":"end","in":"query","description":"End datetime in ISO 8601 format (e.g., '2024-01-15T11:00:00Z')","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Maximum number of samples (default: 10,000,000)","required":false,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Series data in requested format","content":{"application/json":{"schema":{}}}},"400":{"description":"Invalid format"},"404":{"description":"Series not found"}}}}},"components":{"schemas":{"AppError":{"oneOf":[{"type":"object","required":["InternalServerError"],"properties":{"InternalServerError":{"type":"string"}},"example":"Internal Server Error"},{"type":"object","required":["BadRequest"],"properties":{"BadRequest":{"type":"string"}},"example":"Bad Request"},{"type":"object","required":["NotFound"],"properties":{"NotFound":{"type":"string"}},"example":"Not Found"},{"type":"object","required":["Unauthorized"],"properties":{"Unauthorized":{"type":"string"}},"example":"Unauthorized"},{"type":"object","required":["Forbidden"],"properties":{"Forbidden":{"type":"string"}},"example":"Forbidden"},{"type":"object","required":["Storage"],"properties":{"Storage":{"type":"string"}},"example":"Storage Error"}]},"HealthResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"ReadinessResponse":{"type":"object","required":["status","database"],"properties":{"database":{"type":"string"},"error":{"type":["string","null"]},"status":{"type":"string"}}}}},"tags":[{"name":"SensApp","description":"SensApp API"},{"name":"InfluxDB","description":"InfluxDB Write API"},{"name":"Observability","description":"Prometheus-compatible service metrics"},{"name":"Prometheus","description":"Prometheus Remote Write and Read API"},{"name":"Admin","description":"Administrative operations"},{"name":"Health","description":"Health check endpoints"}]} +{ + "components": { + "schemas": { + "AppError": { + "oneOf": [ + { + "example": "Internal Server Error", + "properties": { + "InternalServerError": { + "type": "string" + } + }, + "required": [ + "InternalServerError" + ], + "type": "object" + }, + { + "example": "Bad Request", + "properties": { + "BadRequest": { + "type": "string" + } + }, + "required": [ + "BadRequest" + ], + "type": "object" + }, + { + "example": "Not Found", + "properties": { + "NotFound": { + "type": "string" + } + }, + "required": [ + "NotFound" + ], + "type": "object" + }, + { + "example": "Unauthorized", + "properties": { + "Unauthorized": { + "type": "string" + } + }, + "required": [ + "Unauthorized" + ], + "type": "object" + }, + { + "example": "Forbidden", + "properties": { + "Forbidden": { + "type": "string" + } + }, + "required": [ + "Forbidden" + ], + "type": "object" + }, + { + "example": "Storage Error", + "properties": { + "Storage": { + "type": "string" + } + }, + "required": [ + "Storage" + ], + "type": "object" + } + ] + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ReadinessResponse": { + "properties": { + "database": { + "type": "string" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "database" + ], + "type": "object" + }, + "VacuumResponse": { + "description": "What a vacuum did.", + "properties": { + "duplicates_removed": { + "description": "Number of duplicate samples removed (same series, same timestamp, same value, the first\none written is kept), or `null` when the storage backend cannot remove duplicates", + "format": "int64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "status": { + "description": "`ok` when the maintenance completed", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + } + }, + "info": { + "description": "Sensor data ingestion, storage, and retrieval platform", + "license": { + "identifier": "Apache-2.0", + "name": "Apache-2.0" + }, + "title": "sensapp", + "version": "0.3.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "frontpage", + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served" + } + }, + "tags": [ + "SensApp" + ] + } + }, + "/api/v1/admin/vacuum": { + "post": { + "description": "Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle\nof a request leave some), then cleans up and optimizes the database and reclaims space, as far as\nthe storage backend supports it. Only exact duplicates go: two different values at the same\ntimestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list.", + "operationId": "vacuum_database", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VacuumResponse" + } + } + }, + "description": "Maintenance completed, with the number of duplicate samples removed" + }, + "403": { + "description": "The token does not have the delete scope, or has a sensor allow list" + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Failed to vacuum database" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_MAINTENANCE_TIMEOUT_SECONDS (one hour by default), and the storage backend may carry on after the answer" + } + }, + "summary": "Database Vacuuming", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/prometheus_remote_read": { + "post": { + "description": "Allows you to read data from SensApp using Prometheus remote read protocol.\n\nIt follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/).", + "operationId": "prometheus_remote_read", + "parameters": [ + { + "description": "Content encoding, must be snappy", + "in": "header", + "name": "content-encoding", + "required": true, + "schema": { + "format": "snappy", + "type": "string" + } + }, + { + "description": "Content type, must be application/x-protobuf", + "in": "header", + "name": "content-type", + "required": true, + "schema": { + "format": "application/x-protobuf", + "type": "string" + } + }, + { + "description": "Prometheus Remote Read version, must be 0.1.0", + "in": "header", + "name": "x-prometheus-remote-read-version", + "required": true, + "schema": { + "format": "0.1.0", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": {} + }, + "description": "Prometheus Remote Read endpoint. [Reference](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/)" + }, + "responses": { + "200": { + "content": { + "application/x-protobuf": {} + }, + "description": "Read Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Prometheus Remote Read API.", + "tags": [ + "Prometheus" + ] + } + }, + "/api/v1/prometheus_remote_write": { + "post": { + "description": "Allows you to write data from Prometheus to SensApp.\n\nIt follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/).", + "operationId": "publish_prometheus", + "parameters": [ + { + "description": "Content encoding, must be snappy", + "in": "header", + "name": "content-encoding", + "required": true, + "schema": { + "format": "snappy", + "type": "string" + } + }, + { + "description": "Content type, must be application/x-protobuf", + "in": "header", + "name": "content-type", + "required": true, + "schema": { + "format": "application/x-protobuf", + "type": "string" + } + }, + { + "description": "Prometheus Remote Write version, must be 0.1.0", + "in": "header", + "name": "x-prometheus-remote-write-version", + "required": true, + "schema": { + "format": "0.1.0", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": {} + }, + "description": "Prometheus Remote Write endpoint. [Reference](https://prometheus.io/docs/concepts/remote_write_spec/)" + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Prometheus Remote Write API.", + "tags": [ + "Prometheus" + ] + } + }, + "/api/v1/query": { + "get": { + "description": "Parses a PromQL expression and returns matching time series data.\nOnly simple selectors (VectorSelector and MatrixSelector) are supported.\n\n# Examples\n\n- Simple metric: `GET /api/v1/query?query=my_metric`\n- With labels: `GET /api/v1/query?query=my_metric{env=\"prod\"}`\n- Range query: `GET /api/v1/query?query=my_metric[5m]`\n- With format: `GET /api/v1/query?query=my_metric&format=csv`", + "operationId": "simple_promql_query", + "parameters": [ + { + "description": "PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')", + "in": "query", + "name": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Output format: senml (default), csv, jsonl, or arrow", + "in": "query", + "name": "format", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bucket width for aggregation queries, using Prometheus duration syntax (e.g., '5m'). Without it, the whole window is one bucket", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Query results in requested format" + }, + "400": { + "description": "Invalid or unsupported PromQL query" + }, + "500": { + "description": "Internal server error" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Simple PromQL query endpoint.", + "tags": [ + "SensApp" + ] + } + }, + "/api/v2/write": { + "post": { + "description": "Allows you to write data from InfluxDB or Telegraf to SensApp.\n[More information.](https://github.com/SINTEF/sensapp/blob/main/docs/INFLUX_DB.md)", + "operationId": "publish_influxdb", + "parameters": [ + { + "description": "Bucket name", + "example": "sensapp", + "in": "query", + "name": "bucket", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Organization name", + "example": "sensapp", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Organization ID", + "in": "query", + "name": "org_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Precision of the timestamps. One of ns, us, ms, s", + "in": "query", + "name": "precision", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "text/plain": { + "example": "cpu,host=A,region=west usage_system=64.2 1590488773254420000", + "schema": { + "type": "string" + } + } + }, + "description": "InfluxDB Line Protocol endpoint. [Reference](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/).", + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "InfluxDB Compatible Write API.", + "tags": [ + "InfluxDB" + ] + } + }, + "/health/live": { + "get": { + "description": "Checks if the service is running.\nThis endpoint always returns 200 OK if the server is able to respond.", + "operationId": "liveness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Service is alive" + } + }, + "summary": "Liveness check", + "tags": [ + "Health" + ] + } + }, + "/health/ready": { + "get": { + "description": "Checks if the service is ready to accept traffic.\nThis includes checking if the database connection is working", + "operationId": "readiness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Service is ready" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Service is not ready" + } + }, + "summary": "Readiness check", + "tags": [ + "Health" + ] + } + }, + "/metrics": { + "get": { + "operationId": "list_metrics", + "parameters": [ + { + "description": "Filter metrics by name (substring match)", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter metrics by name using regex pattern", + "in": "query", + "name": "name_regex", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter metrics by sensor type (float, integer, string, boolean, location, json, blob, numeric)", + "in": "query", + "name": "type", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Metrics catalog in DCAT format" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "List unique metrics (measurement types) with aggregated information in DCAT catalog format.", + "tags": [ + "SensApp" + ] + } + }, + "/prometheus/metrics": { + "get": { + "operationId": "prometheus_metrics", + "parameters": [ + { + "description": "Append the most recent sample for Prometheus-compatible series", + "in": "query", + "name": "include_latest_samples", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Optional metric name filter used when include_latest_samples=true", + "in": "query", + "name": "metric", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional PromQL-style label selector used when include_latest_samples=true", + "in": "query", + "name": "selector", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Prometheus-compatible metrics" + } + }, + "tags": [ + "Observability" + ] + } + }, + "/publish": { + "post": { + "description": "Accepts sensor data in one of the following formats:\n- **SenML JSON** (RFC 8428): `Content-Type: application/json`\n- **CSV**: `Content-Type: text/csv` or `application/csv`\n- **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.stream`\n\nIf no Content-Type header is provided, defaults to CSV format.", + "operationId": "publish_sensors_data", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Sensor data in SenML JSON, CSV, or Apache Arrow format", + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Data ingested successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request - invalid data format" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "SensApp native data ingestion API supporting multiple formats.", + "tags": [ + "SensApp" + ] + } + }, + "/series": { + "get": { + "operationId": "list_series", + "parameters": [ + { + "description": "Filter series by metric name", + "in": "query", + "name": "metric", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')", + "in": "query", + "name": "selector", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Time series catalog in DCAT format" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "List all series (time series) in DCAT catalog format.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}": { + "delete": { + "description": "Publishing the same sensor again recreates it with the same UUID.\nRequires the `delete` scope when authentication is enabled.", + "operationId": "delete_series", + "parameters": [ + { + "description": "UUID of the series to delete", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Series deleted" + }, + "400": { + "description": "Invalid UUID" + }, + "403": { + "description": "The token does not have the delete scope" + }, + "404": { + "description": "Series not found" + }, + "501": { + "description": "The storage backend does not support deletion" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Delete a series: all its samples, its labels and the sensor itself.", + "tags": [ + "Admin" + ] + }, + "get": { + "operationId": "get_series_data", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Output format: senml, csv, or jsonl (default: senml)", + "in": "query", + "name": "format", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Start datetime in ISO 8601 format (e.g., '2024-01-15T10:30:00Z')", + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "End datetime in ISO 8601 format (e.g., '2024-01-15T11:00:00Z')", + "in": "query", + "name": "end", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of samples (at most 100,000). Returns the oldest N samples of the window, in time order, and silently drops the rest. With simplify, it bounds the rows read before simplification", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Bucket width using Prometheus duration syntax (e.g., '1h', '5m', '1d')", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Aggregation function: avg, min, max, sum, count, first, last", + "in": "query", + "name": "aggregation", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicitly enable simplify-based point reduction. Applied to the rows read for the window (after bucketing when step is set); more than 100,000 rows to simplify is rejected with HTTP 400 instead of being truncated, so use step/aggregation or a narrower start/end", + "in": "query", + "name": "simplify", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Dimensionless simplify tolerance on normalized time/value coordinates", + "in": "query", + "name": "simplify_tolerance", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "description": "Use Douglas-Peucker only when simplifying", + "in": "query", + "name": "simplify_high_quality", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Series data in requested format" + }, + "400": { + "description": "Invalid format" + }, + "404": { + "description": "Series not found" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Get series data in various formats based on query parameter.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/availability": { + "get": { + "operationId": "get_series_availability", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional bucket width using Prometheus duration syntax for coverage calculation", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Availability information for the requested series and window" + }, + "400": { + "description": "Invalid query parameters" + }, + "404": { + "description": "Series not found" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Get presence and optional bucket coverage for a series over a time window.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/last": { + "get": { + "operationId": "get_series_last_sample", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Latest sample for the requested series" + }, + "400": { + "description": "Invalid query parameters" + }, + "404": { + "description": "Series not found or no sample matched the requested window" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Get the most recent sample for a series, optionally within a bounded time window.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/samples": { + "delete": { + "description": "Both bounds are required, so that a request cannot wipe a whole series by\naccident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end`\ndeletes the samples at one exact timestamp. The sensor and its labels are kept.\nRequires the `delete` scope when authentication is enabled.", + "operationId": "delete_series_samples", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Number of deleted samples" + }, + "400": { + "description": "Invalid UUID, or missing or invalid time bounds" + }, + "403": { + "description": "The token does not have the delete scope" + }, + "404": { + "description": "Series not found" + }, + "501": { + "description": "The storage backend does not support deletion" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "summary": "Delete the samples of a series between `start` and `end`, both inclusive.", + "tags": [ + "Admin" + ] + } + } + }, + "tags": [ + { + "description": "SensApp API", + "name": "SensApp" + }, + { + "description": "InfluxDB Write API", + "name": "InfluxDB" + }, + { + "description": "Prometheus-compatible service metrics", + "name": "Observability" + }, + { + "description": "Prometheus Remote Write and Read API", + "name": "Prometheus" + }, + { + "description": "Administrative operations", + "name": "Admin" + }, + { + "description": "Health check endpoints", + "name": "Health" + } + ] +} diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index b6fc2a67..e38b4c67 100644 --- a/frontend/src/client/@tanstack/react-query.gen.ts +++ b/frontend/src/client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { frontpage, getSeriesData, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from '../sdk.gen'; -import type { FrontpageData, FrontpageResponse, GetSeriesDataData, ListMetricsData, ListSeriesData, LivenessData, LivenessResponse, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusRemoteReadData, PrometheusRemoteReadError, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbResponse, PublishPrometheusData, PublishPrometheusError, PublishPrometheusResponse, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataResponse, ReadinessData, ReadinessError, ReadinessResponse2, SimplePromqlQueryData, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseResponse } from '../types.gen'; +import { deleteSeries, deleteSeriesSamples, frontpage, getSeriesAvailability, getSeriesData, getSeriesLastSample, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from '../sdk.gen'; +import type { DeleteSeriesData, DeleteSeriesResponse, DeleteSeriesSamplesData, FrontpageData, FrontpageResponse, GetSeriesAvailabilityData, GetSeriesDataData, GetSeriesLastSampleData, ListMetricsData, ListSeriesData, LivenessData, LivenessResponse, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusRemoteReadData, PrometheusRemoteReadError, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbResponse, PublishPrometheusData, PublishPrometheusError, PublishPrometheusResponse, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataResponse, ReadinessData, ReadinessError, ReadinessResponse2, SimplePromqlQueryData, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -57,8 +57,10 @@ export const frontpageOptions = (options?: Options) => queryOptio /** * Database Vacuuming * - * Cleans up and optimizes the database by removing unused data and reclaiming space. - * (only if supported by the underlying storage engine). + * Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle + * of a request leave some), then cleans up and optimizes the database and reclaims space, as far as + * the storage backend supports it. Only exact duplicates go: two different values at the same + * timestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list. */ export const vacuumDatabaseMutation = (options?: Partial>): UseMutationOptions> => { const mutationOptions: UseMutationOptions> = { @@ -245,7 +247,7 @@ export const prometheusMetricsOptions = (options?: Options) => queryOpt queryKey: listSeriesQueryKey(options) }); +/** + * Delete a series: all its samples, its labels and the sensor itself. + * + * Publishing the same sensor again recreates it with the same UUID. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSeries({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSeriesDataQueryKey = (options: Options) => createQueryKey('getSeriesData', options); /** @@ -357,3 +379,121 @@ export const getSeriesDataInfiniteOptions = (options: Options }); return opts as Omit; }; + +export const getSeriesAvailabilityQueryKey = (options: Options) => createQueryKey('getSeriesAvailability', options); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailabilityOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSeriesAvailability({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesAvailabilityQueryKey(options) +}); + +export const getSeriesAvailabilityInfiniteQueryKey = (options: Options): QueryKey> => createQueryKey('getSeriesAvailability', options, true); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailabilityInfiniteOptions = (options: Options) => { + const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { + query: { + start: pageParam + } + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSeriesAvailability({ + ...options, + ...params, + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesAvailabilityInfiniteQueryKey(options) + }); + return opts as Omit; +}; + +export const getSeriesLastSampleQueryKey = (options: Options) => createQueryKey('getSeriesLastSample', options); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSampleOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSeriesLastSample({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesLastSampleQueryKey(options) +}); + +export const getSeriesLastSampleInfiniteQueryKey = (options: Options): QueryKey> => createQueryKey('getSeriesLastSample', options, true); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSampleInfiniteOptions = (options: Options) => { + const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { + query: { + start: pageParam + } + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSeriesLastSample({ + ...options, + ...params, + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesLastSampleInfiniteQueryKey(options) + }); + return opts as Omit; +}; + +/** + * Delete the samples of a series between `start` and `end`, both inclusive. + * + * Both bounds are required, so that a request cannot wipe a whole series by + * accident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end` + * deletes the samples at one exact timestamp. The sensor and its labels are kept. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesSamplesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSeriesSamples({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index ac5804b9..c1c19c98 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { frontpage, getSeriesData, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from './sdk.gen'; -export type { AppError, ClientOptions, FrontpageData, FrontpageResponse, FrontpageResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, HealthResponse, ListMetricsData, ListMetricsResponses, ListSeriesData, ListSeriesResponses, LivenessData, LivenessResponse, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadError, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbErrors, PublishInfluxdbResponse, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusError, PublishPrometheusErrors, PublishPrometheusResponse, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataErrors, PublishSensorsDataResponse, PublishSensorsDataResponses, ReadinessData, ReadinessError, ReadinessErrors, ReadinessResponse, ReadinessResponse2, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseErrors, VacuumDatabaseResponse, VacuumDatabaseResponses } from './types.gen'; +export { deleteSeries, deleteSeriesSamples, frontpage, getSeriesAvailability, getSeriesData, getSeriesLastSample, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from './sdk.gen'; +export type { AppError, ClientOptions, DeleteSeriesData, DeleteSeriesErrors, DeleteSeriesResponse, DeleteSeriesResponses, DeleteSeriesSamplesData, DeleteSeriesSamplesErrors, DeleteSeriesSamplesResponses, FrontpageData, FrontpageResponse, FrontpageResponses, GetSeriesAvailabilityData, GetSeriesAvailabilityErrors, GetSeriesAvailabilityResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, GetSeriesLastSampleData, GetSeriesLastSampleErrors, GetSeriesLastSampleResponses, HealthResponse, ListMetricsData, ListMetricsErrors, ListMetricsResponses, ListSeriesData, ListSeriesErrors, ListSeriesResponses, LivenessData, LivenessResponse, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadError, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbErrors, PublishInfluxdbResponse, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusError, PublishPrometheusErrors, PublishPrometheusResponse, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataErrors, PublishSensorsDataResponse, PublishSensorsDataResponses, ReadinessData, ReadinessError, ReadinessErrors, ReadinessResponse, ReadinessResponse2, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseErrors, VacuumDatabaseResponse, VacuumDatabaseResponses, VacuumResponse } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 0b059f90..2b5580d3 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { FrontpageData, FrontpageResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, ListMetricsData, ListMetricsResponses, ListSeriesData, ListSeriesResponses, LivenessData, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbErrors, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusErrors, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataErrors, PublishSensorsDataResponses, ReadinessData, ReadinessErrors, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseErrors, VacuumDatabaseResponses } from './types.gen'; +import type { DeleteSeriesData, DeleteSeriesErrors, DeleteSeriesResponses, DeleteSeriesSamplesData, DeleteSeriesSamplesErrors, DeleteSeriesSamplesResponses, FrontpageData, FrontpageResponses, GetSeriesAvailabilityData, GetSeriesAvailabilityErrors, GetSeriesAvailabilityResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, GetSeriesLastSampleData, GetSeriesLastSampleErrors, GetSeriesLastSampleResponses, ListMetricsData, ListMetricsErrors, ListMetricsResponses, ListSeriesData, ListSeriesErrors, ListSeriesResponses, LivenessData, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbErrors, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusErrors, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataErrors, PublishSensorsDataResponses, ReadinessData, ReadinessErrors, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseErrors, VacuumDatabaseResponses } from './types.gen'; export type Options = Options2 & { /** @@ -23,8 +23,10 @@ export const frontpage = (options?: Option /** * Database Vacuuming * - * Cleans up and optimizes the database by removing unused data and reclaiming space. - * (only if supported by the underlying storage engine). + * Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle + * of a request leave some), then cleans up and optimizes the database and reclaims space, as far as + * the storage backend supports it. Only exact duplicates go: two different values at the same + * timestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list. */ export const vacuumDatabase = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/v1/admin/vacuum', ...options }); @@ -96,7 +98,7 @@ export const readiness = (options?: Option /** * List unique metrics (measurement types) with aggregated information in DCAT catalog format. */ -export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/metrics', ...options }); +export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/metrics', ...options }); export const prometheusMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/prometheus/metrics', ...options }); @@ -106,7 +108,7 @@ export const prometheusMetrics = (options? * Accepts sensor data in one of the following formats: * - **SenML JSON** (RFC 8428): `Content-Type: application/json` * - **CSV**: `Content-Type: text/csv` or `application/csv` - * - **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.file` + * - **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.stream` * * If no Content-Type header is provided, defaults to CSV format. */ @@ -123,9 +125,37 @@ export const publishSensorsData = (options /** * List all series (time series) in DCAT catalog format. */ -export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/series', ...options }); +export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/series', ...options }); + +/** + * Delete a series: all its samples, its labels and the sensor itself. + * + * Publishing the same sensor again recreates it with the same UUID. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeries = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/series/{series_uuid}', ...options }); /** * Get series data in various formats based on query parameter. */ export const getSeriesData = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}', ...options }); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailability = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}/availability', ...options }); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSample = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}/last', ...options }); + +/** + * Delete the samples of a series between `start` and `end`, both inclusive. + * + * Both bounds are required, so that a request cannot wipe a whole series by + * accident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end` + * deletes the samples at one exact timestamp. The sensor and its labels are kept. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesSamples = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/series/{series_uuid}/samples', ...options }); diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0ad6570e..615cd03a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -28,6 +28,21 @@ export type ReadinessResponse = { status: string; }; +/** + * What a vacuum did. + */ +export type VacuumResponse = { + /** + * Number of duplicate samples removed (same series, same timestamp, same value, the first + * one written is kept), or `null` when the storage backend cannot remove duplicates + */ + duplicates_removed?: number | null; + /** + * `ok` when the maintenance completed + */ + status: string; +}; + export type FrontpageData = { body?: never; path?: never; @@ -37,7 +52,7 @@ export type FrontpageData = { export type FrontpageResponses = { /** - * SensApp Frontpage + * SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served */ 200: string; }; @@ -52,19 +67,31 @@ export type VacuumDatabaseData = { }; export type VacuumDatabaseErrors = { + /** + * The token does not have the delete scope, or has a sensor allow list + */ + 403: unknown; /** * Failed to vacuum database */ 500: string; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_MAINTENANCE_TIMEOUT_SECONDS (one hour by default), and the storage backend may carry on after the answer + */ + 504: unknown; }; export type VacuumDatabaseError = VacuumDatabaseErrors[keyof VacuumDatabaseErrors]; export type VacuumDatabaseResponses = { /** - * Database vacuum completed successfully + * Maintenance completed, with the number of duplicate samples removed */ - 200: string; + 200: VacuumResponse; }; export type VacuumDatabaseResponse = VacuumDatabaseResponses[keyof VacuumDatabaseResponses]; @@ -102,6 +129,14 @@ export type PrometheusRemoteReadErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type PrometheusRemoteReadError = PrometheusRemoteReadErrors[keyof PrometheusRemoteReadErrors]; @@ -146,6 +181,14 @@ export type PublishPrometheusErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type PublishPrometheusError = PublishPrometheusErrors[keyof PublishPrometheusErrors]; @@ -171,6 +214,10 @@ export type SimplePromqlQueryData = { * Output format: senml (default), csv, jsonl, or arrow */ format?: string; + /** + * Bucket width for aggregation queries, using Prometheus duration syntax (e.g., '5m'). Without it, the whole window is one bucket + */ + step?: string; }; url: '/api/v1/query'; }; @@ -184,6 +231,14 @@ export type SimplePromqlQueryErrors = { * Internal server error */ 500: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type SimplePromqlQueryResponses = { @@ -229,6 +284,14 @@ export type PublishInfluxdbErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type PublishInfluxdbError = PublishInfluxdbErrors[keyof PublishInfluxdbErrors]; @@ -303,6 +366,17 @@ export type ListMetricsData = { url: '/metrics'; }; +export type ListMetricsErrors = { + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + export type ListMetricsResponses = { /** * Metrics catalog in DCAT format @@ -313,7 +387,20 @@ export type ListMetricsResponses = { export type PrometheusMetricsData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Append the most recent sample for Prometheus-compatible series + */ + include_latest_samples?: boolean; + /** + * Optional metric name filter used when include_latest_samples=true + */ + metric?: string; + /** + * Optional PromQL-style label selector used when include_latest_samples=true + */ + selector?: string; + }; url: '/prometheus/metrics'; }; @@ -345,6 +432,14 @@ export type PublishSensorsDataErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type PublishSensorsDataError = PublishSensorsDataErrors[keyof PublishSensorsDataErrors]; @@ -374,6 +469,17 @@ export type ListSeriesData = { url: '/series'; }; +export type ListSeriesErrors = { + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + export type ListSeriesResponses = { /** * Time series catalog in DCAT format @@ -381,6 +487,54 @@ export type ListSeriesResponses = { 200: unknown; }; +export type DeleteSeriesData = { + body?: never; + path: { + /** + * UUID of the series to delete + */ + series_uuid: string; + }; + query?: never; + url: '/series/{series_uuid}'; +}; + +export type DeleteSeriesErrors = { + /** + * Invalid UUID + */ + 400: unknown; + /** + * The token does not have the delete scope + */ + 403: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend does not support deletion + */ + 501: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type DeleteSeriesResponses = { + /** + * Series deleted + */ + 204: void; +}; + +export type DeleteSeriesResponse = DeleteSeriesResponses[keyof DeleteSeriesResponses]; + export type GetSeriesDataData = { body?: never; path: { @@ -403,9 +557,29 @@ export type GetSeriesDataData = { */ end?: string; /** - * Maximum number of samples (default: 10,000,000) + * Maximum number of samples (at most 100,000). Returns the oldest N samples of the window, in time order, and silently drops the rest. With simplify, it bounds the rows read before simplification */ limit?: number; + /** + * Bucket width using Prometheus duration syntax (e.g., '1h', '5m', '1d') + */ + step?: string; + /** + * Aggregation function: avg, min, max, sum, count, first, last + */ + aggregation?: string; + /** + * Explicitly enable simplify-based point reduction. Applied to the rows read for the window (after bucketing when step is set); more than 100,000 rows to simplify is rejected with HTTP 400 instead of being truncated, so use step/aggregation or a narrower start/end + */ + simplify?: boolean; + /** + * Dimensionless simplify tolerance on normalized time/value coordinates + */ + simplify_tolerance?: number; + /** + * Use Douglas-Peucker only when simplifying + */ + simplify_high_quality?: boolean; }; url: '/series/{series_uuid}'; }; @@ -419,6 +593,14 @@ export type GetSeriesDataErrors = { * Series not found */ 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type GetSeriesDataResponses = { @@ -427,3 +609,156 @@ export type GetSeriesDataResponses = { */ 200: unknown; }; + +export type GetSeriesAvailabilityData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query: { + /** + * Inclusive start datetime in ISO 8601 format + */ + start: string; + /** + * Inclusive end datetime in ISO 8601 format + */ + end: string; + /** + * Optional bucket width using Prometheus duration syntax for coverage calculation + */ + step?: string; + }; + url: '/series/{series_uuid}/availability'; +}; + +export type GetSeriesAvailabilityErrors = { + /** + * Invalid query parameters + */ + 400: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type GetSeriesAvailabilityResponses = { + /** + * Availability information for the requested series and window + */ + 200: unknown; +}; + +export type GetSeriesLastSampleData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query?: { + /** + * Optional inclusive start datetime in ISO 8601 format + */ + start?: string; + /** + * Optional inclusive end datetime in ISO 8601 format + */ + end?: string; + }; + url: '/series/{series_uuid}/last'; +}; + +export type GetSeriesLastSampleErrors = { + /** + * Invalid query parameters + */ + 400: unknown; + /** + * Series not found or no sample matched the requested window + */ + 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type GetSeriesLastSampleResponses = { + /** + * Latest sample for the requested series + */ + 200: unknown; +}; + +export type DeleteSeriesSamplesData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query: { + /** + * Inclusive start datetime in ISO 8601 format + */ + start: string; + /** + * Inclusive end datetime in ISO 8601 format + */ + end: string; + }; + url: '/series/{series_uuid}/samples'; +}; + +export type DeleteSeriesSamplesErrors = { + /** + * Invalid UUID, or missing or invalid time bounds + */ + 400: unknown; + /** + * The token does not have the delete scope + */ + 403: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend does not support deletion + */ + 501: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type DeleteSeriesSamplesResponses = { + /** + * Number of deleted samples + */ + 200: unknown; +}; diff --git a/src/http/server.rs b/src/http/server.rs index d3194758..21bf4d95 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -591,6 +591,31 @@ mod tests { } } + /// The client of the frontend is generated from `frontend/openapi.json`, so that file is the + /// document of this server. After an API change, rewrite it and regenerate the client: + /// `UPDATE_OPENAPI=1 cargo test frontend_openapi_document`, then `npm run openapi-ts` in `frontend/`. + #[test] + fn frontend_openapi_document_is_up_to_date() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/frontend/openapi.json"); + let current = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); + + if std::env::var_os("UPDATE_OPENAPI").is_some() { + let json = serde_json::to_string_pretty(¤t).expect("format OpenAPI document"); + std::fs::write(path, json + "\n").expect("write frontend/openapi.json"); + return; + } + + let saved: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).expect("read frontend/openapi.json"), + ) + .expect("parse frontend/openapi.json"); + assert!( + saved == current, + "frontend/openapi.json is not the OpenAPI document of the server: run \ + `UPDATE_OPENAPI=1 cargo test frontend_openapi_document`, then `npm run openapi-ts` in frontend/" + ); + } + #[test] fn data_endpoints_document_overload_and_timeout_answers() { let document = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); From 7ff3a44be9fa224620a1f436585cfa47cc7917ef Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:41:05 +0200 Subject: [PATCH 05/53] =?UTF-8?q?feat:=20=E2=9C=A8=20aggregate=20wide=20ch?= =?UTF-8?q?art=20ranges,=20and=20polish=20the=20UI=20for=20small=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The server refuses more than 100 000 raw samples of a series: past about 2.8 hours the chart asks for a round step (at most 2 000 buckets) averaged, only for numeric series (aggregation on a string series is a 500, see ideas/), and says so in the chart header. - The series list says when the server cut it at 256 series. - Small screens: the page scrolls and the panels keep a usable height, the header fits, the date range wraps. - One Loading and one ErrorAlert replace the copies in the two tables, the invalid CSS and the dead useSeriesData hook are gone, the series checkboxes are labelled. - CI uses Node 24 like the image, and the container smoke test checks the UI. - docs/FRONTEND.md, ideas for what was left out. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 +- docs/CI.md | 6 +- docs/FRONTEND.md | 17 ++- frontend/src/components/Feedback.tsx | 21 ++++ frontend/src/components/HealthBadge.tsx | 2 +- frontend/src/components/Layout.tsx | 8 +- frontend/src/components/MetricsTable.tsx | 17 +-- frontend/src/components/SeriesTable.test.tsx | 85 +++++++++++++++ frontend/src/components/SeriesTable.tsx | 23 ++-- frontend/src/components/TimeRangeSelector.tsx | 2 +- .../src/components/TimeSeriesChart.test.tsx | 100 ++++++++++++++++++ frontend/src/components/TimeSeriesChart.tsx | 19 ++-- frontend/src/hooks/useSeriesData.ts | 43 -------- frontend/src/index.css | 17 ++- frontend/src/lib/chartStep.test.ts | 46 ++++++++ frontend/src/lib/chartStep.ts | 47 ++++++++ frontend/src/pages/ExplorerPage.tsx | 22 +++- ideas/frontend-next-steps.md | 19 ++++ ideas/metrics-tests-need-loaded-config.md | 7 ++ ideas/string-series-aggregation-500.md | 11 ++ tests/clickhouse_container_smoke.py | 16 +++ 21 files changed, 428 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/Feedback.tsx create mode 100644 frontend/src/components/SeriesTable.test.tsx create mode 100644 frontend/src/components/TimeSeriesChart.test.tsx delete mode 100644 frontend/src/hooks/useSeriesData.ts create mode 100644 frontend/src/lib/chartStep.test.ts create mode 100644 frontend/src/lib/chartStep.ts create mode 100644 ideas/frontend-next-steps.md create mode 100644 ideas/metrics-tests-need-loaded-config.md create mode 100644 ideas/string-series-aggregation-500.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7616c566..5eade8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: frontend/package-lock.json diff --git a/docs/CI.md b/docs/CI.md index 2939b589..7c915da8 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -6,7 +6,8 @@ tags, published releases, and manual dispatch. ## What is checked - Rust formatting, a blocking `cargo audit` (with the documented RSA advisory - exception in `Makefile.toml`), frontend lint/typecheck/tests/build and a + exception in `Makefile.toml`), frontend lint/typecheck/tests/build (a Rust test + also checks that `frontend/openapi.json` is the OpenAPI document of the server) and a high-severity npm audit, and Python 3.14 SDK unit tests, lint, package build and wheel install smoke test. @@ -23,7 +24,8 @@ see `ideas/remove-frontend-js-yaml-override.md` for the override follow-up. ([BIGQUERY.md](BIGQUERY.md)). It does not block the ClickHouse release path. - Helm lint/template/package and Docker builds. The normal runtime image is actually started with ClickHouse, then `tests/clickhouse_container_smoke.py` - checks readiness, publish, query, and service metrics. Live Python SDK tests + checks readiness, publish, query, service metrics, and that the web UI is served + (the image builds it in its own Node stage). Live Python SDK tests also run against this image. CI stops ClickHouse and verifies that readiness becomes unhealthy, restarts it, and verifies recovery. The image smoke job is skipped on published releases: the tagged commit already passed it on `main`, diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a27a88c1..79d581a8 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -9,6 +9,12 @@ SensApp ships a small explorer: pick a metric, pick series, draw them over a tim - The files are public. Responses carry a `Content-Security-Policy` (the page only talks to its own origin, and cannot be framed), `X-Content-Type-Options: nosniff` and `Cache-Control: no-cache`. - The UI calls the SensApp API of its own origin: `/metrics`, `/series`, `/series/{uuid}` and `/health/ready`. +## Charts + +The server refuses to send more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)), so the chart asks for a `step` once the time range is longer than about 2.8 hours: the range is cut in at most 2 000 buckets of a round duration (`10s`, `1m`, `10m`, `1h`, …) and the samples of a bucket are averaged (`aggregation=avg`). The chart header says "averaged per 1m". Shorter ranges are read as they are. Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. + +The list of series is cut by the server at 256 series: the UI says so, and a label selector narrows it. + ## Authentication With `SENSAPP_JWT_SECRET` unset the UI works without further ado. When it is set, the API answers `401` and the UI shows a dialog asking for a token: paste the output of `sensapp generate-token --scope read`. See [JWT_AUTH.md](JWT_AUTH.md). @@ -29,4 +35,13 @@ npm run dev # http://localhost:5173/ui/, proxies the API to http://localh npm run lint && npm run typecheck && npm test && npm run build ``` -Run SensApp on port 3000 next to it (`cargo run`). `npm run openapi-ts` regenerates `src/client` from `openapi.json` (the generated files are committed). +Run SensApp on port 3000 next to it (`cargo run`). + +`src/client` is generated from `openapi.json`, and both are committed. `openapi.json` is the OpenAPI document of the server: a Rust test fails when it is out of date. After an API change: + +```bash +UPDATE_OPENAPI=1 cargo test frontend_openapi_document # rewrites frontend/openapi.json +cd frontend && npm run openapi-ts # regenerates src/client +``` + +`VITE_SENSAPP_LIVE_URL=http://localhost:3000 npm test` also runs the test that talks to a running SensApp (without authentication). diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx new file mode 100644 index 00000000..5f53a4db --- /dev/null +++ b/frontend/src/components/Feedback.tsx @@ -0,0 +1,21 @@ +/** The spinner of a table that loads. */ +export function Loading() { + return ( +
+ + Loading... +
+ ); +} + +/** What failed, in the place of the data. */ +export function ErrorAlert({ what, error }: { what: string; error: unknown }) { + return ( +
+ + Failed to load {what}: {error instanceof Error ? error.message : 'Unknown error'} +
+ ); +} diff --git a/frontend/src/components/HealthBadge.tsx b/frontend/src/components/HealthBadge.tsx index e13fdb8d..37a30b8b 100644 --- a/frontend/src/components/HealthBadge.tsx +++ b/frontend/src/components/HealthBadge.tsx @@ -21,7 +21,7 @@ export function HealthBadge() { return (
- + {isHealthy ? 'Connected' : 'Disconnected'}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2d04e21e..921a4d95 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,10 +5,10 @@ import { HealthBadge } from './HealthBadge'; export function Layout() { return ( -
+
-
+
diff --git a/frontend/src/components/MetricsTable.tsx b/frontend/src/components/MetricsTable.tsx index a2a53b25..f6526521 100644 --- a/frontend/src/components/MetricsTable.tsx +++ b/frontend/src/components/MetricsTable.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useMetrics } from '../hooks/useMetrics'; import type { DcatDataset } from '../hooks/useMetrics'; import { useSelectionStore } from '../stores/useSelectionStore'; +import { ErrorAlert, Loading } from './Feedback'; const SENSOR_TYPES = [ '', @@ -83,21 +84,9 @@ export function MetricsTable() {
- {isLoading && ( -
- - Loading... -
- )} + {isLoading && } - {error && ( -
- - - - Failed to load metrics: {error instanceof Error ? error.message : 'Unknown error'} -
- )} + {error && } {!isLoading && !error && metrics.length === 0 && (
diff --git a/frontend/src/components/SeriesTable.test.tsx b/frontend/src/components/SeriesTable.test.tsx new file mode 100644 index 00000000..f6072c75 --- /dev/null +++ b/frontend/src/components/SeriesTable.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { SeriesTable } from './SeriesTable'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +const mockListSeries = vi.fn(); +vi.mock('../client', () => ({ + listSeries: (...args: unknown[]) => mockListSeries(...args), +})); + +const dataset = (uuid: string, host: string) => ({ + '@type': 'dcat:Dataset', + '@id': `temperature{host="${host}"}`, + 'dct:identifier': uuid, + 'dct:title': 'temperature', + 'sensor:type': 'float', + 'sensor:labels': [{ host }], + 'dcat:distribution': [], +}); + +const catalog = { + '@type': 'dcat:Catalog', + 'dcat:dataset': [dataset('uuid-a', 'alpha'), dataset('uuid-b', 'beta')], +}; + +function renderTable() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe('SeriesTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListSeries.mockResolvedValue({ data: catalog }); + useSelectionStore.setState({ selectedMetric: 'temperature', selectedSeries: [], labelFilter: '' }); + }); + + it('lists the series of the metric and selects them', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(await screen.findByRole('checkbox', { name: 'Select series uuid-a' })); + expect(useSelectionStore.getState().selectedSeries).toEqual([ + { uuid: 'uuid-a', name: 'temperature', labels: { host: 'alpha' }, type: 'float' }, + ]); + expect(mockListSeries).toHaveBeenCalledWith({ query: { metric: 'temperature', selector: undefined } }); + }); + + it('filters the list as people type', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-a'); + + await user.type(screen.getByPlaceholderText('Quick filter...'), 'beta'); + expect(screen.queryByText('uuid-a')).not.toBeInTheDocument(); + expect(screen.getByText('uuid-b')).toBeInTheDocument(); + }); + + it('says when the list is cut, the server sends 256 series at most', async () => { + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'hydra:view': { '@type': 'hydra:PartialCollectionView', 'hydra:next': '/series?bookmark=x', 'hydra:itemsPerPage': 2 } }, + }); + renderTable(); + expect(await screen.findByText(/more series exist/)).toBeInTheDocument(); + }); + + it('does not say it when the list is complete', async () => { + renderTable(); + await screen.findByText('uuid-a'); + expect(screen.queryByText(/more series exist/)).not.toBeInTheDocument(); + }); + + it('shows what failed', async () => { + mockListSeries.mockResolvedValue({ error: { error: 'Selector is invalid' }, response: { status: 400 } }); + renderTable(); + expect(await screen.findByText(/Failed to load series: Selector is invalid/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index af311959..e6e57c37 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useSeries } from '../hooks/useSeries'; import type { SeriesDataset } from '../hooks/useSeries'; import { useSelectionStore } from '../stores/useSelectionStore'; +import { ErrorAlert, Loading } from './Feedback'; function labelsToRecord( labels?: Array> @@ -88,21 +89,9 @@ export function SeriesTable() {
- {isLoading && ( -
- - Loading... -
- )} + {isLoading && } - {error && ( -
- - - - Failed to load series: {error instanceof Error ? error.message : 'Unknown error'} -
- )} + {error && } {!isLoading && !error && filteredSeries.length === 0 && (
@@ -142,6 +131,7 @@ export function SeriesTable() { handleToggle(s)} onClick={(e) => e.stopPropagation()} @@ -181,6 +171,11 @@ export function SeriesTable() {
{filteredSeries.length} series {filteredSeries.length !== series.length && ` (${series.length} total)`} + {data?.['hydra:view'] && ( + + {' '}· more series exist: narrow the list with a selector + + )}
)} diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index 62f1e2d8..6e6a74e8 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -51,7 +51,7 @@ export function TimeRangeSelector() { ))}
-
+
from ({ + getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), +})); + +const START = '2026-10-04T00:00:00.000Z'; +const hours = (n: number) => new Date(Date.parse(START) + n * 3600_000).toISOString(); + +const temperature = { uuid: 'uuid-temperature', name: 'temperature', labels: {}, type: 'float' }; +const state = { uuid: 'uuid-state', name: 'state', labels: {}, type: 'string' }; + +function renderChart() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +function queryOf(uuid: string) { + return mockGetSeriesData.mock.calls + .map(([options]) => options) + .find((options) => options.path.series_uuid === uuid)?.query; +} + +describe('TimeSeriesChart', () => { + beforeEach(() => { + vi.clearAllMocks(); + // SenML: the base time is in the first record, the others are relative to it + mockGetSeriesData.mockResolvedValue({ + data: [{ bt: 1_791_000_000, t: 0, v: 1 }, { t: 30, v: 2 }, { t: 60, v: 3 }], + }); + }); + + it('averages the numeric series of a wide range, the server refuses to send them raw', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(24) }, + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(queryOf('uuid-temperature')).toMatchObject({ step: '1m', aggregation: 'avg' }); + // Not the others: strings cannot be averaged + expect(queryOf('uuid-state')).not.toHaveProperty('step'); + expect(queryOf('uuid-state')).not.toHaveProperty('aggregation'); + }); + + it('reads a short range as it is', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(0.25) }, + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + expect(queryOf('uuid-temperature')).toEqual({ + format: 'senml', + start: START, + end: hours(0.25), + }); + }); + + it('draws one line per series', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + expect(await screen.findByTestId('echarts')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + }); + + it('says which series failed, and draws the others', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + path.series_uuid === 'uuid-state' + ? { error: { error: 'Internal Server Error' }, response: { status: 500 } } + : { data: [{ bt: 1_791_000_000, t: 0, v: 1 }] }, + ), + ); + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + expect(await screen.findByText(/Some series failed to load: Internal Server Error/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + }); +}); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 75caa041..6a3104e4 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -3,7 +3,7 @@ import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; -import type { SenMLRecord } from '../hooks/useSeriesData'; +import { chartStep, isNumericType } from '../lib/chartStep'; // echarts is large: load it when the first chart is drawn. const EChart = lazy(() => import('./EChart')); @@ -21,21 +21,24 @@ function buildSeriesLabel(name: string, labels: Record): string return labelStr ? `${name}{${labelStr}}` : name; } +/** One SenML record. Only the first of a pack has the base time (`bt`), the others are relative to it. */ +interface SenMLRecord { + bt?: number; + t?: number; + v?: number; + vs?: string; +} + function parseSenMLToTimeSeries( records: SenMLRecord[] ): Array<[number, number]> { - let baseName = ''; let baseTime = 0; const points: Array<[number, number]> = []; for (const rec of records) { - if (rec.bn !== undefined) baseName = rec.bn; if (rec.bt !== undefined) baseTime = rec.bt; - // We only care about re-assigning baseName for multi-record packs - void baseName; - const time = (baseTime + (rec.t ?? 0)) * 1000; // Convert to ms const value = rec.v ?? (rec.vs ? parseFloat(rec.vs) : NaN); @@ -50,6 +53,8 @@ function parseSenMLToTimeSeries( export function TimeSeriesChart() { const { selectedSeries, timeRange } = useSelectionStore(); + const step = chartStep(timeRange.start, timeRange.end); + const queries = useQueries({ queries: selectedSeries.map((s, index) => ({ queryKey: ['seriesData', s.uuid, timeRange] as const, @@ -60,6 +65,8 @@ export function TimeSeriesChart() { format: 'senml', start: timeRange.start, end: timeRange.end, + // A wide range is averaged per step, the server refuses to send more than 100 000 raw samples + ...(step && isNumericType(s.type) ? { step, aggregation: 'avg' } : {}), }, }); return { diff --git a/frontend/src/hooks/useSeriesData.ts b/frontend/src/hooks/useSeriesData.ts deleted file mode 100644 index ac5fd950..00000000 --- a/frontend/src/hooks/useSeriesData.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getSeriesData } from '../client'; -import { unwrap } from '../api/clientConfig'; - -export interface SenMLRecord { - bn?: string; - bt?: number; - bu?: string; - n?: string; - t?: number; - u?: string; - v?: number; - vs?: string; - vb?: boolean; - vd?: string; -} - -export function useSeriesData( - seriesUuid: string | undefined, - options?: { - start?: string; - end?: string; - format?: string; - limit?: number; - } -) { - return useQuery({ - queryKey: ['seriesData', seriesUuid, options], - queryFn: async () => { - const result = await getSeriesData({ - path: { series_uuid: seriesUuid! }, - query: { - format: options?.format || 'senml', - start: options?.start, - end: options?.end, - limit: options?.limit, - }, - }); - return unwrap(result) as unknown as SenMLRecord[]; - }, - enabled: !!seriesUuid, - }); -} diff --git a/frontend/src/index.css b/frontend/src/index.css index e7637ded..022462c3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -28,17 +28,14 @@ --font-sans: Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } +/* The page scrolls on small screens. From lg on it is one screen, and the panels scroll. */ html, body, #root { - height: 100%; - overflow: hidden; + min-height: 100%; } -/* Smooth transitions for interactive elements */ -.transition-card { - transition: box-shadow 0.2s ease, border-color 0.2s ease; -} - -/* Better table row hover */ -tr.cursor-pointer:hover td { - background-color: oklch(var(--color-primary) / 0.04); +@media (min-width: 1024px) { + html, body, #root { + height: 100%; + overflow: hidden; + } } diff --git a/frontend/src/lib/chartStep.test.ts b/frontend/src/lib/chartStep.test.ts new file mode 100644 index 00000000..aa3c3670 --- /dev/null +++ b/frontend/src/lib/chartStep.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { chartStep, isNumericType } from './chartStep'; + +const START = '2026-10-04T00:00:00.000Z'; +const at = (seconds: number) => new Date(Date.parse(START) + seconds * 1000).toISOString(); + +describe('chartStep', () => { + it('reads short ranges as they are', () => { + expect(chartStep(START, at(15 * 60))).toBeUndefined(); + expect(chartStep(START, at(2000))).toBeUndefined(); + expect(chartStep(START, at(3600))).toBeUndefined(); + expect(chartStep(START, at(10_000))).toBeUndefined(); + }); + + it('aggregates the ranges of the presets of the UI', () => { + expect(chartStep(START, at(10_001))).toBe('10s'); + expect(chartStep(START, at(6 * 3600))).toBe('15s'); + expect(chartStep(START, at(24 * 3600))).toBe('1m'); + expect(chartStep(START, at(7 * 86400))).toBe('10m'); + expect(chartStep(START, at(30 * 86400))).toBe('30m'); + }); + + it('never makes more than 2000 points, up to a year', () => { + const seconds = (step: string) => parseInt(step) * { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[step.slice(-1) as 's']!; + for (const days of [1, 3, 10, 45, 90, 180, 365]) { + const step = chartStep(START, at(days * 86400))!; + expect((days * 86400) / seconds(step)).toBeLessThanOrEqual(2000); + } + }); + + it('uses the longest step for a range beyond the table', () => { + expect(chartStep(START, at(20 * 365 * 86400))).toBe('1w'); + }); + + it('reads a range that makes no sense as it is', () => { + expect(chartStep(at(100), START)).toBeUndefined(); + expect(chartStep('not a date', START)).toBeUndefined(); + }); +}); + +describe('isNumericType', () => { + it('knows what can be averaged', () => { + for (const type of ['float', 'Float', 'integer', 'numeric']) expect(isNumericType(type)).toBe(true); + for (const type of ['string', 'boolean', 'location', 'json', 'blob']) expect(isNumericType(type)).toBe(false); + }); +}); diff --git a/frontend/src/lib/chartStep.ts b/frontend/src/lib/chartStep.ts new file mode 100644 index 00000000..df4b8ffe --- /dev/null +++ b/frontend/src/lib/chartStep.ts @@ -0,0 +1,47 @@ +/** Longest raw read of `GET /series/{uuid}` is 100 000 samples: wide ranges must be aggregated. */ +const MAX_POINTS = 2000; + +/** Below this step the samples are read as they are: averaging 5 seconds hides nothing. */ +const MIN_STEP_SECONDS = 5; + +/** Round steps, in seconds, with the Prometheus duration that says them. */ +const STEPS: Array<[number, string]> = [ + [5, '5s'], + [10, '10s'], + [15, '15s'], + [30, '30s'], + [60, '1m'], + [120, '2m'], + [300, '5m'], + [600, '10m'], + [900, '15m'], + [1800, '30m'], + [3600, '1h'], + [7200, '2h'], + [10800, '3h'], + [21600, '6h'], + [43200, '12h'], + [86400, '1d'], + [604800, '1w'], +]; + +const NUMERIC_TYPES = ['float', 'integer', 'numeric']; + +/** Only numbers can be averaged: the server refuses (or fails) on the other types. */ +export function isNumericType(type: string): boolean { + return NUMERIC_TYPES.includes(type.toLowerCase()); +} + +/** + * The `step` that keeps a chart of this range under `MAX_POINTS` points, or `undefined` when the + * range is short enough (under about 2.8 hours) to be read as it is. The samples of a step are + * averaged. + */ +export function chartStep(start: string, end: string): string | undefined { + const seconds = (new Date(end).getTime() - new Date(start).getTime()) / 1000; + if (!(seconds > MAX_POINTS)) return undefined; + const wanted = seconds / MAX_POINTS; + if (wanted <= MIN_STEP_SECONDS) return undefined; + const [, step] = STEPS.find(([length]) => length >= wanted) ?? STEPS[STEPS.length - 1]; + return step; +} diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 9142b151..4e7668a0 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -2,14 +2,18 @@ import { MetricsTable } from '../components/MetricsTable'; import { SeriesTable } from '../components/SeriesTable'; import { TimeSeriesChart } from '../components/TimeSeriesChart'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; +import { chartStep, isNumericType } from '../lib/chartStep'; import { useSelectionStore } from '../stores/useSelectionStore'; export function ExplorerPage() { - const { selectedMetric, selectedSeries, clearSelectedSeries } = + const { selectedMetric, selectedSeries, clearSelectedSeries, timeRange } = useSelectionStore(); + // Wide ranges are averaged per step: say so + const step = chartStep(timeRange.start, timeRange.end); + const averaged = step && selectedSeries.some((s) => isNumericType(s.type)); return ( -
+
{/* Chart + Time Range — always on top, à la InfluxDB */}
@@ -19,6 +23,14 @@ export function ExplorerPage() { {selectedSeries.length} series )} + {averaged && ( + + averaged per {step} + + )}
@@ -42,9 +54,9 @@ export function ExplorerPage() {
{/* Metrics + Series side-by-side below the chart */} -
+
{/* Metrics panel */} -
+

Metrics

{/* Series panel */} -
+

{selectedMetric ? ( diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md new file mode 100644 index 00000000..5d119cc1 --- /dev/null +++ b/ideas/frontend-next-steps.md @@ -0,0 +1,19 @@ +# Frontend: next steps + +What was left out of `done/frontend-good-enough.md` on purpose, most useful first. + +- **Dark mode.** daisyUI has the `dark` theme but only follows the OS scheme for a theme flagged + `--prefersdark`, so it is never selected. Enable it, and give echarts a dark theme (axis and legend text, + tooltip background). +- **Series beyond the first 256.** The UI says that more exist (`hydra:view`). A "load more" with the + `bookmark` of `hydra:next` would list them. +- **Selection in the URL.** Metric, series and time range as query parameters, so that a chart can be shared + or reloaded. Small with the router already in place. +- **Non-numeric series.** Strings, booleans and locations are listed but not drawn. A table of the latest + values (`/series/{uuid}/last`) would show them. +- **Refresh.** A live "last 15 minutes" that follows the clock, with an auto-refresh interval. +- **Several instances, one UI.** The API base is `VITE_SENSAPP_API_URL` at build time. A runtime setting would + allow one build against any instance; only useful if the UI is ever served apart from SensApp. +- **TypeScript 7.** Held at 5.9 until `typescript-eslint` supports it (peer range `<6.1`). +- The DCAT types of `src/hooks/useMetrics.ts` and `useSeries.ts` are written by hand because the OpenAPI + document types the catalogs as a plain string. Typing them in the server would remove the hand-written copy. diff --git a/ideas/metrics-tests-need-loaded-config.md b/ideas/metrics-tests-need-loaded-config.md new file mode 100644 index 00000000..1dfe4174 --- /dev/null +++ b/ideas/metrics-tests-need-loaded-config.md @@ -0,0 +1,7 @@ +# Two `http::metrics` unit tests only pass when another test loaded the configuration + +`http::metrics::tests::latest_prometheus_line_uses_original_timestamp` and +`scrape_compatibility_requires_numeric_sensor_and_valid_names` call `config::get()`, which is only set once some +test ran `load_configuration_for_tests()`. The full `cargo test --lib` passes, but +`cargo test --lib http::metrics` (or `http::`) alone fails with "Configuration not loaded". Seen on `main` on +4 October 2026. Call `load_configuration_for_tests()` at the start of both tests. diff --git a/ideas/string-series-aggregation-500.md b/ideas/string-series-aggregation-500.md new file mode 100644 index 00000000..1511f0fc --- /dev/null +++ b/ideas/string-series-aggregation-500.md @@ -0,0 +1,11 @@ +# `aggregation` on a string series answers 500 + +Found on 4 October 2026 while building the chart of the web UI. + +`GET /series/{uuid}?step=1m&aggregation=avg` on a string series (a sensor published with `vs`) answers +`500 Internal Server Error`, on SQLite at least. The same request without `step` answers 200. An average of +strings is a client mistake, so the answer should be a `400` that says which types an aggregation supports, +and it should be the same on every backend. Add a backend-generic test (string and boolean series with +every aggregation) next to the other series query tests. + +The UI works around it: it only aggregates `float`, `integer` and `numeric` series. diff --git a/tests/clickhouse_container_smoke.py b/tests/clickhouse_container_smoke.py index cb705fe2..2cc4eaa6 100644 --- a/tests/clickhouse_container_smoke.py +++ b/tests/clickhouse_container_smoke.py @@ -3,6 +3,7 @@ import argparse import json +import re import sys import time import urllib.error @@ -40,6 +41,20 @@ def wait_for_readiness(base_url: str, expected_status: int, timeout: int) -> Non raise AssertionError(f"Readiness did not reach HTTP {expected_status}: {last_result}") +def check_ui(base_url: str) -> None: + """The image ships the web UI: `/` leads to it, and the script it loads is there.""" + status, body = request(base_url, "/") # urllib follows the redirect to /ui/ + if status != 200 or '
' not in body: + raise AssertionError(f"The web UI is not served at /: HTTP {status}: {body[:200]}") + script = re.search(r'src="(/ui/assets/[^"]+\.js)"', body) + if not script: + raise AssertionError(f"The web UI page loads no script: {body[:200]}") + status, _ = request(base_url, script.group(1)) + if status != 200: + raise AssertionError(f"The web UI script {script.group(1)} answers HTTP {status}") + print(f"Web UI served, with {script.group(1)}") + + def lifecycle(base_url: str) -> None: wait_for_readiness(base_url, 200, 120) metric = f"sensapp_ci_{uuid.uuid4().hex}" @@ -65,6 +80,7 @@ def lifecycle(base_url: str) -> None: if status != 200 or not body.strip(): raise AssertionError(f"Service metrics unavailable: HTTP {status}: {body}") print(f"Published and queried {metric}; service metrics available") + check_ui(base_url) def main() -> int: From c8455c20226e7eff11740cc2d00e7fdef42dfa4a Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 12:42:28 +0200 Subject: [PATCH 06/53] =?UTF-8?q?docs:=20=F0=9F=93=9D=20close=20the=20fron?= =?UTF-8?q?tend=20task=20with=20its=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- current_tasks/frontend-good-enough.md | 29 --------------- done/frontend-good-enough.md | 53 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 29 deletions(-) delete mode 100644 current_tasks/frontend-good-enough.md create mode 100644 done/frontend-good-enough.md diff --git a/current_tasks/frontend-good-enough.md b/current_tasks/frontend-good-enough.md deleted file mode 100644 index 08987ba8..00000000 --- a/current_tasks/frontend-good-enough.md +++ /dev/null @@ -1,29 +0,0 @@ -# Frontend: good enough, minimal, served by SensApp - -Branch: `frontend-good-enough` (no pull request yet). - -## Goal - -A small, solid explorer UI (metrics, series, chart) that ships with the SensApp image and works with and -without JWT authentication. Keep it minimal. - -## Decisions - -- No separate container. The Docker build gets a Node stage that builds the frontend; the image carries - the static files and SensApp serves them under `/ui/`. -- SensApp serves the UI by default (`SENSAPP_UI_ENABLED=true`). When enabled, `/` redirects to `/ui/`. - The files come from `SENSAPP_UI_DIR`; a missing directory is a warning, not a startup failure. -- Authentication: there is no login endpoint (tokens come from `sensapp generate-token`), so on a 401 or 403 - the UI asks for a token, keeps it for the browser tab only (`sessionStorage`) and sends it as a Bearer token. - -## Steps - -1. [ ] Analysis, dependency refresh -2. [ ] Serve the UI under `/ui/` from SensApp (config, redirect, Docker stage, docs) -3. [ ] Authentication in the frontend (token dialog, 401/403 handling, tests, live check against a JWT server) -4. [ ] Code quality pass (error handling in one place, smaller bundle, dead code, README) -5. [ ] Live check in a browser, then the next steps - -## Findings - -Filled in as the work goes. diff --git a/done/frontend-good-enough.md b/done/frontend-good-enough.md new file mode 100644 index 00000000..a5d8254d --- /dev/null +++ b/done/frontend-good-enough.md @@ -0,0 +1,53 @@ +# Frontend: good enough, minimal, served by SensApp + +Branch: `frontend-good-enough` (no pull request yet). + +## Goal + +A small, solid explorer UI (metrics, series, chart) that ships with the SensApp image and works with and +without JWT authentication. Keep it minimal. + +## Decisions + +- No separate container. The Docker build gets a Node stage that builds the frontend; the image carries + the static files and SensApp serves them under `/ui/`. +- SensApp serves the UI by default (`SENSAPP_UI_ENABLED=true`). When enabled, `/` redirects to `/ui/`. + The files come from `SENSAPP_UI_DIR`; a missing directory is a warning, not a startup failure. +- Authentication: there is no login endpoint (tokens come from `sensapp generate-token`), so on a 401 or 403 + the UI asks for a token, keeps it for the browser tab only (`sessionStorage`) and sends it as a Bearer token. + +## Steps + +1. [x] Analysis, dependency refresh +2. [x] Serve the UI under `/ui/` from SensApp (config, redirect, Docker stage, docs) +3. [x] Authentication in the frontend (token dialog, 401/403 handling, tests, live check against a JWT server) +4. [x] Code quality pass (error handling in one place, smaller bundle, dead code, README) +5. [x] Live check in a browser, then the next steps: `ideas/frontend-next-steps.md` + +## Results + +Dependencies: everything is at its latest major (Vite 8, Vitest 5, ESLint 10, jsdom 30) except TypeScript, held +at 5.9 because `typescript-eslint` does not support 7. `echarts-for-react` is gone (unmaintained, imports an +undeclared `tslib`): a 40 line wrapper on echarts core with only the line chart. `npm audit`: 0 findings. + +Build output: 51 asset files (1.7 MB) down to 9 (1.1 MB): Latin-only Roboto, echarts loaded only when a chart is +drawn (1 146 kB to 583 kB, 384 to 196 kB gzipped). The app itself is 103 kB gzipped. + +Serving: `src/http/ui.rs`, `SENSAPP_UI_ENABLED` / `SENSAPP_UI_DIR`, `/` redirects to `/ui/`. Public files with a +CSP, nosniff and no-cache. Verified in a container built from the Dockerfile (Node stage on the build platform, +files in `/usr/share/sensapp/ui`, runs as the non-root user), and `tests/clickhouse_container_smoke.py` now +checks it in CI. + +Authentication: verified against a live SensApp with JWT enabled and in a browser: no token, an invalid token +(401, "Invalid token: InvalidToken"), a write-only token (403, "Read access required"), a read token, a reload +(the token survives in the tab), Sign out (token and data forgotten, dialog again). + +Found on the way, fixed: `frontend/openapi.json` had drifted from the server (a test now guards it and the +client is regenerated); raw reads over 100 000 samples are refused by the server, so wide ranges are averaged by +a `step`; the page was unusable on a phone (clipped header and date range, a Metrics panel squeezed to a sliver). + +Found on the way, left for later (in `ideas/`): `string-series-aggregation-500.md` (server), +`metrics-tests-need-loaded-config.md` (two unit tests that depend on test order), `frontend-next-steps.md`. + +Not checked: the Dockerfile build for linux/arm64 (the Node stage is platform independent), and the complete +`docker-smoke` job with ClickHouse (only its new `check_ui` step, against an image with SQLite). From e3160608b90b7b053ef201fc53c758a494195ac9 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 13:09:38 +0200 Subject: [PATCH 07/53] =?UTF-8?q?feat:=20=E2=9C=A8=20page=20the=20series?= =?UTF-8?q?=20list,=20follow=20the=20OS=20dark=20theme,=20drop=20the=20exp?= =?UTF-8?q?laining=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The series list has Previous/Next on the bookmark of the server instead of stopping at 256 series; the selection is kept from page to page. /series now documents limit and bookmark, the client is regenerated. - Dark mode: the daisyUI dark theme was declared but never selected. It is now, and echarts uses its dark theme and follows the OS. - No more text in the chart header about averaging, and a shorter token dialog. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 4 +- done/frontend-good-enough.md | 5 ++ frontend/openapi.json | 21 ++++- frontend/src/client/types.gen.ts | 10 ++- frontend/src/components/AuthDialog.tsx | 9 +- frontend/src/components/EChart.tsx | 12 ++- frontend/src/components/SeriesTable.test.tsx | 88 +++++++++++++++++--- frontend/src/components/SeriesTable.tsx | 42 ++++++++-- frontend/src/hooks/useSeries.ts | 9 ++ frontend/src/index.css | 2 +- frontend/src/lib/usePrefersDark.test.ts | 49 +++++++++++ frontend/src/lib/usePrefersDark.ts | 18 ++++ frontend/src/pages/ExplorerPage.tsx | 16 +--- ideas/frontend-next-steps.md | 5 -- src/http/crud.rs | 6 +- 15 files changed, 238 insertions(+), 58 deletions(-) create mode 100644 frontend/src/lib/usePrefersDark.test.ts create mode 100644 frontend/src/lib/usePrefersDark.ts diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 79d581a8..f50760ec 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -11,9 +11,9 @@ SensApp ships a small explorer: pick a metric, pick series, draw them over a tim ## Charts -The server refuses to send more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)), so the chart asks for a `step` once the time range is longer than about 2.8 hours: the range is cut in at most 2 000 buckets of a round duration (`10s`, `1m`, `10m`, `1h`, …) and the samples of a bucket are averaged (`aggregation=avg`). The chart header says "averaged per 1m". Shorter ranges are read as they are. Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. +The server refuses to send more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)), so the chart asks for a `step` once the time range is longer than about 2.8 hours: the range is cut in at most 2 000 buckets of a round duration (`10s`, `1m`, `10m`, `1h`, …) and the samples of a bucket are averaged (`aggregation=avg`). Shorter ranges are read as they are. Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. -The list of series is cut by the server at 256 series: the UI says so, and a label selector narrows it. +The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. ## Authentication diff --git a/done/frontend-good-enough.md b/done/frontend-good-enough.md index a5d8254d..6da9e842 100644 --- a/done/frontend-good-enough.md +++ b/done/frontend-good-enough.md @@ -49,5 +49,10 @@ a `step`; the page was unusable on a phone (clipped header and date range, a Met Found on the way, left for later (in `ideas/`): `string-series-aggregation-500.md` (server), `metrics-tests-need-loaded-config.md` (two unit tests that depend on test order), `frontend-next-steps.md`. +Review round (4 October 2026): the "averaged per" label in the chart header is gone (a UI that explains itself in +text is noise), the series list is paged with Previous/Next on the server's bookmark (tested live with 300 series; +`/series` now documents `limit` and `bookmark` in the OpenAPI document), and dark mode works: the daisyUI `dark` +theme was declared but not selected (`--prefersdark`), echarts now uses its dark theme and follows the OS. + Not checked: the Dockerfile build for linux/arm64 (the Node stage is platform independent), and the complete `docker-smoke` job with ClickHouse (only its new `check_ui` step, against an image with SQLite). diff --git a/frontend/openapi.json b/frontend/openapi.json index 1b0f071f..85baa5c7 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -787,6 +787,25 @@ "schema": { "type": "string" } + }, + { + "description": "Series per page, 256 by default, 16384 at most", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page", + "in": "query", + "name": "bookmark", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -796,7 +815,7 @@ "schema": {} } }, - "description": "Time series catalog in DCAT format" + "description": "Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page" }, "503": { "description": "The storage backend is unavailable: retry later" diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 615cd03a..efed8c87 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -465,6 +465,14 @@ export type ListSeriesData = { * PromQL-style label selector (e.g., '{env="prod",region=~"us.*"}') */ selector?: string; + /** + * Series per page, 256 by default, 16384 at most + */ + limit?: number; + /** + * Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page + */ + bookmark?: string; }; url: '/series'; }; @@ -482,7 +490,7 @@ export type ListSeriesErrors = { export type ListSeriesResponses = { /** - * Time series catalog in DCAT format + * Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page */ 200: unknown; }; diff --git a/frontend/src/components/AuthDialog.tsx b/frontend/src/components/AuthDialog.tsx index d89c73cd..a7299ab1 100644 --- a/frontend/src/components/AuthDialog.tsx +++ b/frontend/src/components/AuthDialog.tsx @@ -33,10 +33,7 @@ export function AuthDialog() {

{token ? 'This token is not accepted' : 'Authentication required'}

-

- This SensApp asks for a token. Paste one generated with: -

-
+        
           sensapp generate-token ui --scope read
         
@@ -60,10 +57,6 @@ export function AuthDialog() { onChange={(event) => setInput(event.target.value)} /> -

- Kept for this browser tab only, and sent to this server only. -

-
+ +
)}

diff --git a/frontend/src/hooks/useSeries.ts b/frontend/src/hooks/useSeries.ts index a93a7ad9..3dab76b9 100644 --- a/frontend/src/hooks/useSeries.ts +++ b/frontend/src/hooks/useSeries.ts @@ -36,9 +36,17 @@ export interface SeriesCatalog { }; } +/** The cursor of the next page, in the `hydra:next` link of a page. */ +export function nextBookmark(catalog?: SeriesCatalog): string | undefined { + const next = catalog?.['hydra:view']?.['hydra:next']; + if (!next) return undefined; + return new URL(next, 'http://sensapp').searchParams.get('bookmark') ?? undefined; +} + export function useSeries(filters?: { metric?: string; selector?: string; + bookmark?: string; }) { return useQuery({ queryKey: ['series', filters], @@ -47,6 +55,7 @@ export function useSeries(filters?: { query: { metric: filters?.metric, selector: filters?.selector, + bookmark: filters?.bookmark, }, }); return unwrap(result) as unknown as SeriesCatalog; diff --git a/frontend/src/index.css b/frontend/src/index.css index 022462c3..c2980562 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,7 +4,7 @@ @import "@fontsource/roboto/latin-700.css"; @plugin "daisyui" { - themes: light --default, dark; + themes: light --default, dark --prefersdark; logs: false; } diff --git a/frontend/src/lib/usePrefersDark.test.ts b/frontend/src/lib/usePrefersDark.test.ts new file mode 100644 index 00000000..d85959a0 --- /dev/null +++ b/frontend/src/lib/usePrefersDark.test.ts @@ -0,0 +1,49 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { usePrefersDark } from './usePrefersDark'; + +function mockScheme(initiallyDark: boolean) { + let dark = initiallyDark; + const listeners = new Set<() => void>(); + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return dark; + }, + addEventListener: (_: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_: string, listener: () => void) => listeners.delete(listener), + })); + return { + set(value: boolean) { + dark = value; + listeners.forEach((listener) => listener()); + }, + listeners, + }; +} + +describe('usePrefersDark', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reads the scheme of the OS', () => { + mockScheme(true); + expect(renderHook(() => usePrefersDark()).result.current).toBe(true); + mockScheme(false); + expect(renderHook(() => usePrefersDark()).result.current).toBe(false); + }); + + it('follows it when it changes, and stops listening on unmount', () => { + const scheme = mockScheme(false); + const { result, unmount } = renderHook(() => usePrefersDark()); + act(() => scheme.set(true)); + expect(result.current).toBe(true); + act(() => scheme.set(false)); + expect(result.current).toBe(false); + unmount(); + expect(scheme.listeners.size).toBe(0); + }); + + it('is light where the browser cannot tell', () => { + vi.stubGlobal('matchMedia', undefined); + expect(renderHook(() => usePrefersDark()).result.current).toBe(false); + }); +}); diff --git a/frontend/src/lib/usePrefersDark.ts b/frontend/src/lib/usePrefersDark.ts new file mode 100644 index 00000000..4ed8ad60 --- /dev/null +++ b/frontend/src/lib/usePrefersDark.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from 'react'; + +const QUERY = '(prefers-color-scheme: dark)'; + +function subscribe(onChange: () => void) { + const media = window.matchMedia?.(QUERY); + media?.addEventListener('change', onChange); + return () => media?.removeEventListener('change', onChange); +} + +/** Whether the OS asks for a dark theme, following its changes. daisyUI follows it for the page. */ +export function usePrefersDark(): boolean { + return useSyncExternalStore( + subscribe, + () => window.matchMedia?.(QUERY).matches ?? false, + () => false, + ); +} diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 4e7668a0..7f3d04d1 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -2,15 +2,11 @@ import { MetricsTable } from '../components/MetricsTable'; import { SeriesTable } from '../components/SeriesTable'; import { TimeSeriesChart } from '../components/TimeSeriesChart'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; -import { chartStep, isNumericType } from '../lib/chartStep'; import { useSelectionStore } from '../stores/useSelectionStore'; export function ExplorerPage() { - const { selectedMetric, selectedSeries, clearSelectedSeries, timeRange } = + const { selectedMetric, selectedSeries, clearSelectedSeries } = useSelectionStore(); - // Wide ranges are averaged per step: say so - const step = chartStep(timeRange.start, timeRange.end); - const averaged = step && selectedSeries.some((s) => isNumericType(s.type)); return (
@@ -23,14 +19,6 @@ export function ExplorerPage() { {selectedSeries.length} series )} - {averaged && ( - - averaged per {step} - - )}
@@ -88,7 +76,7 @@ export function ExplorerPage() {
{selectedMetric ? ( - + ) : (

Select a metric to browse series

diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md index 5d119cc1..d617e76c 100644 --- a/ideas/frontend-next-steps.md +++ b/ideas/frontend-next-steps.md @@ -2,11 +2,6 @@ What was left out of `done/frontend-good-enough.md` on purpose, most useful first. -- **Dark mode.** daisyUI has the `dark` theme but only follows the OS scheme for a theme flagged - `--prefersdark`, so it is never selected. Enable it, and give echarts a dark theme (axis and legend text, - tooltip background). -- **Series beyond the first 256.** The UI says that more exist (`hydra:view`). A "load more" with the - `bookmark` of `hydra:next` would list them. - **Selection in the URL.** Metric, series and time range as query parameters, so that a chart can be shared or reloaded. Small with the router already in place. - **Non-numeric series.** Strings, booleans and locations are listed but not drawn. A table of the latest diff --git a/src/http/crud.rs b/src/http/crud.rs index 23701d9c..18857330 100644 --- a/src/http/crud.rs +++ b/src/http/crud.rs @@ -623,10 +623,12 @@ async fn list_filtered_series( tag = "SensApp", params( ("metric" = Option, Query, description = "Filter series by metric name"), - ("selector" = Option, Query, description = "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')") + ("selector" = Option, Query, description = "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')"), + ("limit" = Option, Query, description = "Series per page, 256 by default, 16384 at most"), + ("bookmark" = Option, Query, description = "Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page") ), responses( - (status = 200, description = "Time series catalog in DCAT format", body = Value), + (status = 200, description = "Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page", body = Value), (status = 503, description = "The storage backend is unavailable: retry later"), (status = 504, description = "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs") ) From 7f1b381f9ca8720dea5541adcb23a7b20c0f9e63 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 13:16:11 +0200 Subject: [PATCH 08/53] =?UTF-8?q?feat:=20=E2=9C=A8=20choose=20the=20step?= =?UTF-8?q?=20and=20the=20aggregation=20of=20the=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto (the range decides), Raw, or a fixed step, with avg, min, max, sum, count, first or last, like the window period and aggregate function of Influx and the resolution of Prometheus. Both are part of the query key, so the chart asks again when they change. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 7 ++- .../src/components/TimeRangeSelector.test.tsx | 34 +++++++++++-- frontend/src/components/TimeRangeSelector.tsx | 37 +++++++++++++- .../src/components/TimeSeriesChart.test.tsx | 49 ++++++++++++++++++- frontend/src/components/TimeSeriesChart.tsx | 12 ++--- frontend/src/lib/chartStep.test.ts | 17 ++++++- frontend/src/lib/chartStep.ts | 13 +++++ frontend/src/stores/useSelectionStore.ts | 12 +++++ 8 files changed, 168 insertions(+), 13 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index f50760ec..6afd756f 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -11,7 +11,12 @@ SensApp ships a small explorer: pick a metric, pick series, draw them over a tim ## Charts -The server refuses to send more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)), so the chart asks for a `step` once the time range is longer than about 2.8 hours: the range is cut in at most 2 000 buckets of a round duration (`10s`, `1m`, `10m`, `1h`, …) and the samples of a bucket are averaged (`aggregation=avg`). Shorter ranges are read as they are. Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. +Two selectors next to the time range control what the server computes, as in the Influx and Prometheus UIs: + +- **Step**: `Auto`, `Raw`, or a fixed duration (`5s` … `1d`). `Auto` reads ranges under about 2.8 hours as they are, and above that cuts the range in at most 2 000 buckets of a round duration, because the server refuses more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)). `Raw` on a range that is too wide gets that refusal. +- **Aggregation**: `avg`, `min`, `max`, `sum`, `count`, `first`, `last`, applied to the samples of each step. Disabled on `Raw`. + +Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. diff --git a/frontend/src/components/TimeRangeSelector.test.tsx b/frontend/src/components/TimeRangeSelector.test.tsx index 294e6f1a..fd08d80e 100644 --- a/frontend/src/components/TimeRangeSelector.test.tsx +++ b/frontend/src/components/TimeRangeSelector.test.tsx @@ -12,13 +12,15 @@ describe('TimeRangeSelector', () => { start: '2025-01-01T00:00:00.000Z', end: '2025-01-01T01:00:00.000Z', }, + step: 'auto', + aggregation: 'avg', }); }); it('renders all preset buttons', () => { render(); for (const label of ['15m', '1h', '6h', '24h', '7d', '30d']) { - expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); } }); @@ -26,7 +28,7 @@ describe('TimeRangeSelector', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('1h')); + await user.click(screen.getByRole('button', { name: '1h' })); const { timeRange } = useSelectionStore.getState(); const diffMs = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime(); @@ -37,7 +39,7 @@ describe('TimeRangeSelector', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('24h')); + await user.click(screen.getByRole('button', { name: '24h' })); const { timeRange } = useSelectionStore.getState(); const diffMs = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime(); @@ -49,4 +51,30 @@ describe('TimeRangeSelector', () => { expect(screen.getByText('from')).toBeInTheDocument(); expect(screen.getByText('to')).toBeInTheDocument(); }); + + it('chooses the step: auto, raw or a duration', async () => { + const user = userEvent.setup(); + render(); + const step = screen.getByRole('combobox', { name: 'Step' }); + expect(step).toHaveValue('auto'); + + await user.selectOptions(step, '5m'); + expect(useSelectionStore.getState().step).toBe('5m'); + await user.selectOptions(step, 'raw'); + expect(useSelectionStore.getState().step).toBe('raw'); + }); + + it('chooses the aggregation, which has no use on raw data', async () => { + const user = userEvent.setup(); + render(); + const aggregation = screen.getByRole('combobox', { name: 'Aggregation' }); + expect(aggregation).toHaveValue('avg'); + expect(aggregation).toBeEnabled(); + + await user.selectOptions(aggregation, 'max'); + expect(useSelectionStore.getState().aggregation).toBe('max'); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Step' }), 'raw'); + expect(aggregation).toBeDisabled(); + }); }); diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index 6e6a74e8..f336afa1 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -1,3 +1,5 @@ +import { AGGREGATIONS, FIXED_STEPS } from '../lib/chartStep'; +import type { Aggregation } from '../lib/chartStep'; import { useSelectionStore } from '../stores/useSelectionStore'; const PRESETS = [ @@ -10,7 +12,8 @@ const PRESETS = [ ]; export function TimeRangeSelector() { - const { timeRange, setTimeRange } = useSelectionStore(); + const { timeRange, setTimeRange, step, setStep, aggregation, setAggregation } = + useSelectionStore(); function handlePreset(minutes: number) { const end = new Date(); @@ -67,6 +70,38 @@ export function TimeRangeSelector() { onChange={(e) => handleEndChange(e.target.value)} />
+ +
+ + +
); } diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index 75a02710..1d2a0b0e 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'; @@ -34,6 +34,7 @@ function queryOf(uuid: string) { describe('TimeSeriesChart', () => { beforeEach(() => { vi.clearAllMocks(); + useSelectionStore.setState({ step: 'auto', aggregation: 'avg' }); // SenML: the base time is in the first record, the others are relative to it mockGetSeriesData.mockResolvedValue({ data: [{ bt: 1_791_000_000, t: 0, v: 1 }, { t: 30, v: 2 }, { t: 60, v: 3 }], @@ -54,6 +55,52 @@ describe('TimeSeriesChart', () => { expect(queryOf('uuid-state')).not.toHaveProperty('aggregation'); }); + it('sends the step and the aggregation that were chosen, to the numeric series only', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(1) }, + step: '5m', + aggregation: 'max', + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(queryOf('uuid-temperature')).toMatchObject({ step: '5m', aggregation: 'max' }); + expect(queryOf('uuid-state')).not.toHaveProperty('step'); + }); + + it('reads raw when asked to, even on a wide range', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(24) }, + step: 'raw', + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + expect(queryOf('uuid-temperature')).not.toHaveProperty('step'); + expect(queryOf('uuid-temperature')).not.toHaveProperty('aggregation'); + }); + + it('asks again when the step or the aggregation changes', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + step: '5m', + aggregation: 'avg', + }); + renderChart(); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + + act(() => useSelectionStore.getState().setAggregation('min')); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(mockGetSeriesData.mock.lastCall?.[0].query).toMatchObject({ aggregation: 'min' }); + + act(() => useSelectionStore.getState().setStep('1h')); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(3)); + expect(mockGetSeriesData.mock.lastCall?.[0].query).toMatchObject({ step: '1h' }); + }); + it('reads a short range as it is', async () => { useSelectionStore.setState({ selectedSeries: [temperature], diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 6a3104e4..3bd0a373 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -3,7 +3,7 @@ import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; -import { chartStep, isNumericType } from '../lib/chartStep'; +import { isNumericType, resolveStep } from '../lib/chartStep'; // echarts is large: load it when the first chart is drawn. const EChart = lazy(() => import('./EChart')); @@ -51,13 +51,13 @@ function parseSenMLToTimeSeries( } export function TimeSeriesChart() { - const { selectedSeries, timeRange } = useSelectionStore(); + const { selectedSeries, timeRange, step: stepChoice, aggregation } = useSelectionStore(); - const step = chartStep(timeRange.start, timeRange.end); + const step = resolveStep(stepChoice, timeRange.start, timeRange.end); const queries = useQueries({ queries: selectedSeries.map((s, index) => ({ - queryKey: ['seriesData', s.uuid, timeRange] as const, + queryKey: ['seriesData', s.uuid, timeRange, step, aggregation] as const, queryFn: async () => { const result = await getSeriesData({ path: { series_uuid: s.uuid }, @@ -65,8 +65,8 @@ export function TimeSeriesChart() { format: 'senml', start: timeRange.start, end: timeRange.end, - // A wide range is averaged per step, the server refuses to send more than 100 000 raw samples - ...(step && isNumericType(s.type) ? { step, aggregation: 'avg' } : {}), + // Only numbers can be aggregated. The server refuses more than 100 000 raw samples. + ...(step && isNumericType(s.type) ? { step, aggregation } : {}), }, }); return { diff --git a/frontend/src/lib/chartStep.test.ts b/frontend/src/lib/chartStep.test.ts index aa3c3670..0ad13948 100644 --- a/frontend/src/lib/chartStep.test.ts +++ b/frontend/src/lib/chartStep.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { chartStep, isNumericType } from './chartStep'; +import { chartStep, isNumericType, resolveStep } from './chartStep'; const START = '2026-10-04T00:00:00.000Z'; const at = (seconds: number) => new Date(Date.parse(START) + seconds * 1000).toISOString(); @@ -44,3 +44,18 @@ describe('isNumericType', () => { for (const type of ['string', 'boolean', 'location', 'json', 'blob']) expect(isNumericType(type)).toBe(false); }); }); + +describe('resolveStep', () => { + it('lets the range decide on auto', () => { + expect(resolveStep('auto', START, at(24 * 3600))).toBe('1m'); + expect(resolveStep('auto', START, at(3600))).toBeUndefined(); + }); + + it('does not aggregate raw', () => { + expect(resolveStep('raw', START, at(30 * 86400))).toBeUndefined(); + }); + + it('takes a fixed step as it is, whatever the range', () => { + expect(resolveStep('5m', START, at(3600))).toBe('5m'); + }); +}); diff --git a/frontend/src/lib/chartStep.ts b/frontend/src/lib/chartStep.ts index df4b8ffe..5b488cac 100644 --- a/frontend/src/lib/chartStep.ts +++ b/frontend/src/lib/chartStep.ts @@ -45,3 +45,16 @@ export function chartStep(start: string, end: string): string | undefined { const [, step] = STEPS.find(([length]) => length >= wanted) ?? STEPS[STEPS.length - 1]; return step; } + +/** What the step selector offers besides `auto` (the range decides) and `raw` (no aggregation). */ +export const FIXED_STEPS = ['5s', '15s', '30s', '1m', '5m', '15m', '30m', '1h', '6h', '1d']; + +export const AGGREGATIONS = ['avg', 'min', 'max', 'sum', 'count', 'first', 'last'] as const; +export type Aggregation = (typeof AGGREGATIONS)[number]; + +/** The `step` to send for the choice of the selector: `auto`, `raw`, or a duration. */ +export function resolveStep(choice: string, start: string, end: string): string | undefined { + if (choice === 'raw') return undefined; + if (choice === 'auto') return chartStep(start, end); + return choice; +} diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index ddc186c8..0831db57 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import type { Aggregation } from '../lib/chartStep'; interface SelectedSeries { uuid: string; @@ -14,12 +15,17 @@ interface SelectionState { start: string; end: string; }; + /** `auto` (the range decides), `raw`, or a duration such as `5m` */ + step: string; + aggregation: Aggregation; labelFilter: string; setSelectedMetric: (metric: string | null) => void; toggleSeries: (series: SelectedSeries) => void; clearSelectedSeries: () => void; setTimeRange: (start: string, end: string) => void; setLabelFilter: (filter: string) => void; + setStep: (step: string) => void; + setAggregation: (aggregation: Aggregation) => void; } function defaultTimeRange() { @@ -36,6 +42,8 @@ export const useSelectionStore = create((set) => ({ selectedSeries: [], timeRange: defaultTimeRange(), labelFilter: '', + step: 'auto', + aggregation: 'avg', setSelectedMetric: (metric) => set({ selectedMetric: metric, selectedSeries: [] }), @@ -60,4 +68,8 @@ export const useSelectionStore = create((set) => ({ setTimeRange: (start, end) => set({ timeRange: { start, end } }), setLabelFilter: (filter) => set({ labelFilter: filter }), + + setStep: (step) => set({ step }), + + setAggregation: (aggregation) => set({ aggregation }), })); From 76a50969ce00254ab7ad77a946f60a76e6a6b771 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Sun, 4 Oct 2026 13:24:35 +0200 Subject: [PATCH 09/53] =?UTF-8?q?feat:=20=E2=9C=A8=20keep=20the=20explorer?= =?UTF-8?q?=20in=20the=20address,=20draw=20booleans,=20add=20chart=20style?= =?UTF-8?q?s=20and=20a=20log=20scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metric, series, range, step, aggregation and style are query parameters, defaults left out, replacing the history entry. A series is named by its uuid, its name, labels and type come from its first sample; the lookups are queries, so a shared link on a server with a token asks for it and resumes. - Booleans are drawn as a 0/1 step line on an axis of their own, read raw. - Line, step, area, stacked and bars, and a log scale, built by a tested function. - The preset of the range shows as pressed. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 17 +- done/frontend-good-enough.md | 4 + frontend/src/components/ChartOptions.test.tsx | 33 ++++ frontend/src/components/ChartOptions.tsx | 34 ++++ frontend/src/components/EChart.tsx | 3 +- .../src/components/TimeRangeSelector.test.tsx | 16 +- frontend/src/components/TimeRangeSelector.tsx | 23 +-- .../src/components/TimeSeriesChart.test.tsx | 38 +++- frontend/src/components/TimeSeriesChart.tsx | 76 ++------ frontend/src/hooks/useUrlState.test.tsx | 162 ++++++++++++++++++ frontend/src/hooks/useUrlState.ts | 111 ++++++++++++ frontend/src/lib/chartOption.test.ts | 56 ++++++ frontend/src/lib/chartOption.ts | 84 +++++++++ frontend/src/lib/chartStep.ts | 4 + frontend/src/lib/timeRange.ts | 21 +++ frontend/src/lib/urlState.test.ts | 84 +++++++++ frontend/src/lib/urlState.ts | 71 ++++++++ frontend/src/pages/ExplorerPage.tsx | 4 + frontend/src/stores/useSelectionStore.ts | 27 ++- frontend/src/test/setup.tsx | 6 +- ideas/frontend-next-steps.md | 11 +- 21 files changed, 791 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/ChartOptions.test.tsx create mode 100644 frontend/src/components/ChartOptions.tsx create mode 100644 frontend/src/hooks/useUrlState.test.tsx create mode 100644 frontend/src/hooks/useUrlState.ts create mode 100644 frontend/src/lib/chartOption.test.ts create mode 100644 frontend/src/lib/chartOption.ts create mode 100644 frontend/src/lib/timeRange.ts create mode 100644 frontend/src/lib/urlState.test.ts create mode 100644 frontend/src/lib/urlState.ts diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6afd756f..b3ddda4e 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -16,7 +16,22 @@ Two selectors next to the time range control what the server computes, as in the - **Step**: `Auto`, `Raw`, or a fixed duration (`5s` … `1d`). `Auto` reads ranges under about 2.8 hours as they are, and above that cuts the range in at most 2 000 buckets of a round duration, because the server refuses more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)). `Raw` on a range that is too wide gets that refusal. - **Aggregation**: `avg`, `min`, `max`, `sum`, `count`, `first`, `last`, applied to the samples of each step. Disabled on `Raw`. -Series that are not numbers (strings, booleans) are always read as they are, and are not drawn. +Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. + +## Address + +The explorer is in the address, so a link shares a view and a reload keeps it: `/ui/?metric=cpu&series=&series=&range=24h&step=5m&agg=max&style=area&log=1`. + +| Parameter | Meaning | Left out when | +| --- | --- | --- | +| `metric` | the selected metric | none | +| `series` | a selected series, repeated | none | +| `range` | a preset (`15m`, `1h`, `6h`, `24h`, `7d`, `30d`), ending when the page opens | `1h` | +| `from`, `to` | an absolute range (ISO 8601), once dates were typed | a preset is used | +| `step`, `agg` | the step (`raw` or a duration) and the aggregation | `auto`, `avg` | +| `style`, `log` | the chart style, `log=1` for the log scale | `line`, linear | + +The address follows the explorer without adding history entries. What is wrong in an address is ignored; a series that no longer exists is dropped. A series is named by its uuid only: the UI asks the server for the first sample of each one (its name, labels and type), and on a server with authentication a link asks for a token like any other page. The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. diff --git a/done/frontend-good-enough.md b/done/frontend-good-enough.md index 6da9e842..b337e3b6 100644 --- a/done/frontend-good-enough.md +++ b/done/frontend-good-enough.md @@ -54,5 +54,9 @@ text is noise), the series list is paged with Previous/Next on the server's book `/series` now documents `limit` and `bookmark` in the OpenAPI document), and dark mode works: the daisyUI `dark` theme was declared but not selected (`--prefersdark`), echarts now uses its dark theme and follows the OS. +Second review round: step and aggregation selectors; the explorer in the address (`docs/FRONTEND.md`); booleans +drawn as 0/1 step lines; chart styles (line, step, area, stacked, bars) and a log scale. The canvas of the last two +was not looked at in a browser (the pane was hidden), only tested through the option builder and the requests. + Not checked: the Dockerfile build for linux/arm64 (the Node stage is platform independent), and the complete `docker-smoke` job with ClickHouse (only its new `check_ui` step, against an image with SQLite). diff --git a/frontend/src/components/ChartOptions.test.tsx b/frontend/src/components/ChartOptions.test.tsx new file mode 100644 index 00000000..b39c8dc3 --- /dev/null +++ b/frontend/src/components/ChartOptions.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ChartOptions } from './ChartOptions'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +describe('ChartOptions', () => { + beforeEach(() => useSelectionStore.setState({ chartStyle: 'line', logScale: false })); + + it('chooses the style', async () => { + const user = userEvent.setup(); + render(); + const style = screen.getByRole('combobox', { name: 'Style' }); + expect(style).toHaveValue('line'); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['line', 'step', 'area', 'stacked', 'bars']); + + await user.selectOptions(style, 'stacked'); + expect(useSelectionStore.getState().chartStyle).toBe('stacked'); + }); + + it('switches the log scale on and off', async () => { + const user = userEvent.setup(); + render(); + const log = screen.getByRole('button', { name: 'log' }); + expect(log).toHaveAttribute('aria-pressed', 'false'); + + await user.click(log); + expect(useSelectionStore.getState().logScale).toBe(true); + expect(log).toHaveAttribute('aria-pressed', 'true'); + await user.click(log); + expect(useSelectionStore.getState().logScale).toBe(false); + }); +}); diff --git a/frontend/src/components/ChartOptions.tsx b/frontend/src/components/ChartOptions.tsx new file mode 100644 index 00000000..39b51746 --- /dev/null +++ b/frontend/src/components/ChartOptions.tsx @@ -0,0 +1,34 @@ +import { CHART_STYLES } from '../lib/chartOption'; +import type { ChartStyle } from '../lib/chartOption'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +/** How the series are drawn. */ +export function ChartOptions() { + const { chartStyle, setChartStyle, logScale, setLogScale } = useSelectionStore(); + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx index d87f1fcc..c19bb7ef 100644 --- a/frontend/src/components/EChart.tsx +++ b/frontend/src/components/EChart.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import * as echarts from 'echarts/core'; -import { LineChart } from 'echarts/charts'; +import { BarChart, LineChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, @@ -13,6 +13,7 @@ import { usePrefersDark } from '../lib/usePrefersDark'; // Only what the time series chart needs: the full echarts bundle is three times bigger. echarts.use([ + BarChart, LineChart, DataZoomComponent, GridComponent, diff --git a/frontend/src/components/TimeRangeSelector.test.tsx b/frontend/src/components/TimeRangeSelector.test.tsx index fd08d80e..4686ef00 100644 --- a/frontend/src/components/TimeRangeSelector.test.tsx +++ b/frontend/src/components/TimeRangeSelector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, beforeEach } from 'vitest'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; @@ -77,4 +77,18 @@ describe('TimeRangeSelector', () => { await user.selectOptions(screen.getByRole('combobox', { name: 'Step' }), 'raw'); expect(aggregation).toBeDisabled(); }); + + it('marks the preset the range comes from, and none once dates are typed', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '24h' })); + expect(useSelectionStore.getState().relativeRange).toBe('24h'); + expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '1h' })).toHaveAttribute('aria-pressed', 'false'); + + act(() => useSelectionStore.getState().setTimeRange('2026-10-04T10:00:00.000Z', '2026-10-04T11:00:00.000Z')); + expect(useSelectionStore.getState().relativeRange).toBeNull(); + expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index f336afa1..74a9bc89 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -1,26 +1,12 @@ import { AGGREGATIONS, FIXED_STEPS } from '../lib/chartStep'; import type { Aggregation } from '../lib/chartStep'; +import { PRESETS } from '../lib/timeRange'; import { useSelectionStore } from '../stores/useSelectionStore'; -const PRESETS = [ - { label: '15m', minutes: 15 }, - { label: '1h', minutes: 60 }, - { label: '6h', minutes: 360 }, - { label: '24h', minutes: 1440 }, - { label: '7d', minutes: 10080 }, - { label: '30d', minutes: 43200 }, -]; - export function TimeRangeSelector() { - const { timeRange, setTimeRange, step, setStep, aggregation, setAggregation } = + const { timeRange, relativeRange, setTimeRange, setRelativeRange, step, setStep, aggregation, setAggregation } = useSelectionStore(); - function handlePreset(minutes: number) { - const end = new Date(); - const start = new Date(end.getTime() - minutes * 60 * 1000); - setTimeRange(start.toISOString(), end.toISOString()); - } - function handleStartChange(value: string) { if (value) { setTimeRange(new Date(value).toISOString(), timeRange.end); @@ -46,8 +32,9 @@ export function TimeRangeSelector() { {PRESETS.map((preset) => ( diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index 1d2a0b0e..881a7b89 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -15,6 +15,7 @@ const hours = (n: number) => new Date(Date.parse(START) + n * 3600_000).toISOStr const temperature = { uuid: 'uuid-temperature', name: 'temperature', labels: {}, type: 'float' }; const state = { uuid: 'uuid-state', name: 'state', labels: {}, type: 'string' }; +const door = { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean' }; function renderChart() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -34,7 +35,7 @@ function queryOf(uuid: string) { describe('TimeSeriesChart', () => { beforeEach(() => { vi.clearAllMocks(); - useSelectionStore.setState({ step: 'auto', aggregation: 'avg' }); + useSelectionStore.setState({ step: 'auto', aggregation: 'avg', chartStyle: 'line', logScale: false }); // SenML: the base time is in the first record, the others are relative to it mockGetSeriesData.mockResolvedValue({ data: [{ bt: 1_791_000_000, t: 0, v: 1 }, { t: 30, v: 2 }, { t: 60, v: 3 }], @@ -144,4 +145,39 @@ describe('TimeSeriesChart', () => { expect(await screen.findByText(/Some series failed to load: Internal Server Error/)).toBeInTheDocument(); await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); }); + + it('draws a boolean as 0 and 1 on an axis of its own, and reads it raw', async () => { + mockGetSeriesData.mockResolvedValue({ + data: [{ bt: 1_791_000_000, t: 0, vb: true }, { t: 30, vb: false }, { t: 60, vb: true }], + }); + useSelectionStore.setState({ + selectedSeries: [door], + timeRange: { start: START, end: hours(24) }, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ step: 'end', yAxisIndex: 1 }); + expect(option.series[0].data.map(([, value]: [number, number]) => value)).toEqual([1, 0, 1]); + // A boolean cannot be averaged, even on a wide range + expect(queryOf('uuid-door')).not.toHaveProperty('step'); + }); + + it('draws with the style and the scale that were chosen', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + chartStyle: 'stacked', + logScale: true, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ stack: 'total' }); + expect(option.yAxis[0]).toMatchObject({ type: 'log' }); + }); }); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 3bd0a373..56c8982b 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -3,7 +3,8 @@ import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; -import { isNumericType, resolveStep } from '../lib/chartStep'; +import { isBooleanType, isNumericType, resolveStep } from '../lib/chartStep'; +import { buildChartOption } from '../lib/chartOption'; // echarts is large: load it when the first chart is drawn. const EChart = lazy(() => import('./EChart')); @@ -27,6 +28,7 @@ interface SenMLRecord { t?: number; v?: number; vs?: string; + vb?: boolean; } function parseSenMLToTimeSeries( @@ -40,7 +42,8 @@ function parseSenMLToTimeSeries( if (rec.bt !== undefined) baseTime = rec.bt; const time = (baseTime + (rec.t ?? 0)) * 1000; // Convert to ms - const value = rec.v ?? (rec.vs ? parseFloat(rec.vs) : NaN); + // A boolean is 0 or 1 + const value = rec.vb !== undefined ? Number(rec.vb) : (rec.v ?? (rec.vs ? parseFloat(rec.vs) : NaN)); if (!isNaN(value) && isFinite(time)) { points.push([time, value]); @@ -51,7 +54,8 @@ function parseSenMLToTimeSeries( } export function TimeSeriesChart() { - const { selectedSeries, timeRange, step: stepChoice, aggregation } = useSelectionStore(); + const { selectedSeries, timeRange, step: stepChoice, aggregation, chartStyle, logScale } = + useSelectionStore(); const step = resolveStep(stepChoice, timeRange.start, timeRange.end); @@ -83,75 +87,19 @@ export function TimeSeriesChart() { const errors = queries.filter((q) => q.error); const option = useMemo(() => { - const seriesData = queries + const series = queries .filter((q) => q.data) .map((q) => { const { records, series, colorIndex } = q.data!; - const points = parseSenMLToTimeSeries(records); return { name: buildSeriesLabel(series.name, series.labels), - type: 'line' as const, - data: points, - smooth: false, - showSymbol: points.length < 100, - symbol: 'circle', - symbolSize: 3, - lineStyle: { width: 1.5 }, + points: parseSenMLToTimeSeries(records), + boolean: isBooleanType(series.type), color: COLORS[colorIndex % COLORS.length], }; }); - - return { - tooltip: { - trigger: 'axis' as const, - axisPointer: { - type: 'cross' as const, - }, - }, - legend: { - data: seriesData.map((s) => s.name), - type: 'scroll' as const, - bottom: 0, - }, - grid: { - top: 40, - right: 40, - bottom: 60, - left: 60, - }, - xAxis: { - type: 'time' as const, - min: new Date(timeRange.start).getTime(), - max: new Date(timeRange.end).getTime(), - }, - yAxis: { - type: 'value' as const, - scale: true, - }, - dataZoom: [ - { - type: 'inside' as const, - start: 0, - end: 100, - }, - { - type: 'slider' as const, - start: 0, - end: 100, - bottom: 30, - height: 20, - }, - ], - toolbox: { - feature: { - saveAsImage: {}, - dataZoom: {}, - restore: {}, - }, - }, - series: seriesData, - }; - }, [queries, timeRange]); + return buildChartOption(series, chartStyle, logScale, timeRange); + }, [queries, timeRange, chartStyle, logScale]); if (selectedSeries.length === 0) { return null; diff --git a/frontend/src/hooks/useUrlState.test.tsx b/frontend/src/hooks/useUrlState.test.tsx new file mode 100644 index 00000000..c2a30347 --- /dev/null +++ b/frontend/src/hooks/useUrlState.test.tsx @@ -0,0 +1,162 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { createQueryClient } from '../api/queryClient'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import { useUrlState } from './useUrlState'; + +const mockGetSeriesData = vi.fn(); +vi.mock('../client', () => ({ + getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), +})); + +function Probe() { + useUrlState(); + const { search } = useLocation(); + return {search}; +} + +function renderAt(url: string) { + const queryClient = createQueryClient(); + queryClient.setDefaultOptions({ queries: { staleTime: 30_000, retry: false } }); + const view = render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + return { ...view, queryClient }; +} + +const search = () => new URLSearchParams(screen.getByTestId('search').textContent ?? ''); + +/** The first record of a series, as SensApp sends it. */ +const firstRecord = (uuid: string) => + uuid === 'uuid-door' + ? { _name: 'door', bn: uuid, bt: 1, t: 0, vb: true } + : { _name: 'temperature', _labels: { room: 'lab' }, bn: uuid, bt: 1, t: 0, v: 21.5 }; + +describe('useUrlState', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + useSelectionStore.setState({ + selectedMetric: null, + selectedSeries: [], + relativeRange: '1h', + chartStyle: 'line', + logScale: false, + step: 'auto', + aggregation: 'avg', + }); + mockGetSeriesData.mockImplementation(({ path }) => Promise.resolve({ data: [firstRecord(path.series_uuid)] })); + }); + + it('keeps the plain address of the plain explorer', async () => { + renderAt('/'); + await waitFor(() => expect(screen.getByTestId('search')).toBeInTheDocument()); + expect(screen.getByTestId('search')).toHaveTextContent(''); + expect(mockGetSeriesData).not.toHaveBeenCalled(); + }); + + it('shows what the address says', async () => { + renderAt('/?metric=temperature&series=uuid-t&series=uuid-door&range=24h&step=5m&agg=max&style=area&log=1'); + + await waitFor(() => expect(useSelectionStore.getState().selectedSeries).toHaveLength(2)); + const state = useSelectionStore.getState(); + expect(state).toMatchObject({ + selectedMetric: 'temperature', + relativeRange: '24h', + step: '5m', + aggregation: 'max', + chartStyle: 'area', + logScale: true, + }); + // The name, the labels and the type come from the first sample of each series + expect(state.selectedSeries).toEqual([ + { uuid: 'uuid-t', name: 'temperature', labels: { room: 'lab' }, type: 'float' }, + { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean' }, + ]); + const hours = (Date.parse(state.timeRange.end) - Date.parse(state.timeRange.start)) / 3_600_000; + expect(hours).toBe(24); + }); + + it('shows an absolute range as it is', async () => { + renderAt('/?from=2026-10-04T10:00:00.000Z&to=2026-10-04T11:00:00.000Z'); + await waitFor(() => expect(useSelectionStore.getState().relativeRange).toBeNull()); + expect(useSelectionStore.getState().timeRange).toEqual({ + start: '2026-10-04T10:00:00.000Z', + end: '2026-10-04T11:00:00.000Z', + }); + }); + + it('keeps the address in step with the explorer, and not in the history', async () => { + renderAt('/?series=uuid-t&metric=temperature'); + await waitFor(() => expect(useSelectionStore.getState().selectedSeries).toHaveLength(1)); + + act(() => { + useSelectionStore.getState().setStep('1h'); + useSelectionStore.getState().setChartStyle('bars'); + useSelectionStore.getState().setRelativeRange('7d'); + }); + await waitFor(() => expect(search().get('range')).toBe('7d')); + expect(search().get('step')).toBe('1h'); + expect(search().get('style')).toBe('bars'); + expect(search().getAll('series')).toEqual(['uuid-t']); + expect(search().get('metric')).toBe('temperature'); + + act(() => useSelectionStore.getState().clearSelectedSeries()); + await waitFor(() => expect(search().has('series')).toBe(false)); + }); + + it('writes the dates once they were typed', async () => { + renderAt('/'); + await waitFor(() => expect(screen.getByTestId('search')).toBeInTheDocument()); + act(() => useSelectionStore.getState().setTimeRange('2026-10-04T10:00:00.000Z', '2026-10-04T11:00:00.000Z')); + await waitFor(() => expect(search().get('from')).toBe('2026-10-04T10:00:00.000Z')); + expect(search().has('range')).toBe(false); + }); + + it('forgets a series that is gone, and the nonsense of the address', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + path.series_uuid === 'uuid-gone' + ? { error: { error: 'not found' }, response: { status: 404 } } + : { data: [firstRecord(path.series_uuid)] }, + ), + ); + renderAt('/?series=uuid-gone&series=uuid-t&step=7x&style=pie'); + await waitFor(() => expect(useSelectionStore.getState().selectedSeries.map((s) => s.uuid)).toEqual(['uuid-t'])); + expect(useSelectionStore.getState()).toMatchObject({ step: 'auto', chartStyle: 'line' }); + await waitFor(() => expect(search().getAll('series')).toEqual(['uuid-t'])); + expect(search().has('step')).toBe(false); + }); + + it('asks for a token on a shared link, then shows the series', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + useAuthStore.getState().token + ? { data: [firstRecord(path.series_uuid)] } + : { error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }, + ), + ); + const { queryClient } = renderAt('/?series=uuid-t'); + + await waitFor(() => expect(useAuthStore.getState().dialogOpen).toBe(true)); + // Nothing is written while the series are unknown: the link must not be lost + expect(search().getAll('series')).toEqual(['uuid-t']); + expect(useSelectionStore.getState().selectedSeries).toEqual([]); + + // What the token dialog does + act(() => useAuthStore.getState().signIn('a.token.here')); + await act(() => queryClient.invalidateQueries()); + + await waitFor(() => expect(useSelectionStore.getState().selectedSeries.map((s) => s.uuid)).toEqual(['uuid-t'])); + expect(search().getAll('series')).toEqual(['uuid-t']); + }); +}); diff --git a/frontend/src/hooks/useUrlState.ts b/frontend/src/hooks/useUrlState.ts new file mode 100644 index 00000000..7536ce7f --- /dev/null +++ b/frontend/src/hooks/useUrlState.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import { getSeriesData } from '../client'; +import { ApiError, unwrap } from '../api/clientConfig'; +import { fromSearchParams, toSearchParams } from '../lib/urlState'; +import { rangeFor } from '../lib/timeRange'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import type { SelectedSeries } from '../stores/useSelectionStore'; + +/** The type of a series, from the value its first record carries. */ +function typeOf(record: Record): string { + if ('vb' in record) return 'boolean'; + if ('vs' in record) return 'string'; + if ('v' in record) return 'float'; + return 'other'; +} + +/** + * What the chart needs to know of a series that an address names by its uuid: its first sample + * carries the name and the labels. `null` for a series that is gone. + */ +async function probeSeries(uuid: string): Promise { + try { + const result = await getSeriesData({ + path: { series_uuid: uuid }, + query: { format: 'senml', limit: 1 }, + }); + const first = (unwrap(result) as unknown as Array>)[0]; + if (!first) return null; + return { + uuid, + name: typeof first._name === 'string' ? first._name : uuid, + labels: (first._labels ?? {}) as Record, + type: typeOf(first), + }; + } catch (error) { + // Asking for a token is the job of the query cache, anything else is a series to forget + if (error instanceof ApiError && !error.isAuthError) return null; + throw error; + } +} + +/** + * The explorer in the address: what the address says is shown when the page opens, then the + * address follows what is shown (it replaces the history entry, a click is not a page). + */ +export function useUrlState() { + const [params, setParams] = useSearchParams(); + const [initial] = useState(() => fromSearchParams(params)); + + const setParamsRef = useRef(setParams); + useEffect(() => { + setParamsRef.current = setParams; + }); + + // Everything but the series is known at once + useEffect(() => { + const range = initial.relativeRange ? rangeFor(initial.relativeRange) : initial.timeRange; + useSelectionStore.setState({ + ...(initial.metric && { selectedMetric: initial.metric }), + ...(initial.relativeRange !== undefined && { relativeRange: initial.relativeRange }), + ...(range && { timeRange: range }), + ...(initial.step && { step: initial.step }), + ...(initial.aggregation && { aggregation: initial.aggregation }), + ...(initial.chartStyle && { chartStyle: initial.chartStyle }), + ...(initial.logScale && { logScale: true }), + }); + }, [initial]); + + // The series need a request each. It is a query: a token that is asked for is then used. + const probes = useQueries({ + queries: (initial.series ?? []).map((uuid) => ({ + queryKey: ['seriesProbe', uuid], + queryFn: () => probeSeries(uuid), + staleTime: Infinity, + })), + }); + const settled = probes.every( + (probe) => probe.isSuccess || (probe.isError && !(probe.error instanceof ApiError && probe.error.isAuthError)), + ); + + const probesRef = useRef(probes); + useEffect(() => { + probesRef.current = probes; + }); + + // Once the series are known: show them, and from then on the address follows the explorer + useEffect(() => { + if (!settled) return; + const series = probesRef.current.flatMap((probe) => (probe.data ? [probe.data] : [])); + if (series.length > 0) useSelectionStore.setState({ selectedSeries: series }); + + const write = (state: ReturnType) => + setParamsRef.current( + toSearchParams({ + metric: state.selectedMetric, + series: state.selectedSeries.map((s) => s.uuid), + relativeRange: state.relativeRange, + timeRange: state.timeRange, + step: state.step, + aggregation: state.aggregation, + chartStyle: state.chartStyle, + logScale: state.logScale, + }), + { replace: true }, + ); + write(useSelectionStore.getState()); + return useSelectionStore.subscribe(write); + }, [settled]); +} diff --git a/frontend/src/lib/chartOption.test.ts b/frontend/src/lib/chartOption.test.ts new file mode 100644 index 00000000..4218c312 --- /dev/null +++ b/frontend/src/lib/chartOption.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { buildChartOption } from './chartOption'; +import type { ChartSeries } from './chartOption'; + +const RANGE = { start: '2026-10-04T00:00:00.000Z', end: '2026-10-04T01:00:00.000Z' }; +const temperature: ChartSeries = { name: 'temperature', points: [[1, 20], [2, 21]], boolean: false, color: '#111' }; +const door: ChartSeries = { name: 'door', points: [[1, 0], [2, 1]], boolean: true, color: '#222' }; + +type Option = { series: Array>; yAxis: Array>; grid: { right: number } }; +const build = (series: ChartSeries[], style: Parameters[1] = 'line', log = false) => + buildChartOption(series, style, log, RANGE) as unknown as Option; + +describe('buildChartOption', () => { + it('draws a line by default, over the time range', () => { + const option = build([temperature]); + expect(option.series[0]).toMatchObject({ type: 'line', name: 'temperature', stack: undefined, areaStyle: undefined, step: undefined }); + expect(buildChartOption([temperature], 'line', false, RANGE)).toMatchObject({ + xAxis: { min: Date.parse(RANGE.start), max: Date.parse(RANGE.end) }, + }); + }); + + it('knows its styles', () => { + expect(build([temperature], 'step').series[0]).toMatchObject({ type: 'line', step: 'end' }); + expect(build([temperature], 'area').series[0]).toMatchObject({ type: 'line', areaStyle: { opacity: 0.2 } }); + expect(build([temperature], 'stacked').series[0]).toMatchObject({ type: 'line', stack: 'total', areaStyle: {} }); + expect(build([temperature], 'bars').series[0]).toMatchObject({ type: 'bar', showSymbol: false }); + }); + + it('switches the axis to a logarithmic scale', () => { + expect(build([temperature]).yAxis[0]).toMatchObject({ type: 'value', scale: true }); + expect(build([temperature], 'line', true).yAxis[0]).toMatchObject({ type: 'log', scale: false }); + }); + + it('draws booleans as a step line on an axis of their own, in every style', () => { + for (const style of ['line', 'area', 'stacked', 'bars'] as const) { + const option = build([temperature, door], style); + expect(option.series[1]).toMatchObject({ type: 'line', step: 'end', yAxisIndex: 1 }); + expect(option.series[1]).not.toHaveProperty('stack'); + expect(option.series[1]).not.toHaveProperty('areaStyle'); + expect(option.yAxis).toHaveLength(2); + expect(option.yAxis[1]).toMatchObject({ min: 0, max: 1 }); + expect(option.grid.right).toBe(60); + } + }); + + it('has no axis for booleans without booleans', () => { + const option = build([temperature]); + expect(option.yAxis).toHaveLength(1); + expect(option.grid.right).toBe(40); + }); + + it('labels the boolean axis', () => { + const formatter = (build([door]).yAxis[1].axisLabel as { formatter: (v: number) => string }).formatter; + expect([formatter(0), formatter(1)]).toEqual(['false', 'true']); + }); +}); diff --git a/frontend/src/lib/chartOption.ts b/frontend/src/lib/chartOption.ts new file mode 100644 index 00000000..5412e228 --- /dev/null +++ b/frontend/src/lib/chartOption.ts @@ -0,0 +1,84 @@ +import type { EChartsCoreOption } from 'echarts/core'; + +export type ChartStyle = 'line' | 'step' | 'area' | 'stacked' | 'bars'; +export const CHART_STYLES: ChartStyle[] = ['line', 'step', 'area', 'stacked', 'bars']; + +export interface ChartSeries { + name: string; + points: Array<[number, number]>; + /** Drawn as a 0/1 step line on an axis of its own, whatever the style */ + boolean: boolean; + color: string; +} + +/** The echarts option of the time series chart. */ +export function buildChartOption( + series: ChartSeries[], + style: ChartStyle, + logScale: boolean, + range: { start: string; end: string }, +): EChartsCoreOption { + const hasBoolean = series.some((s) => s.boolean); + + const lines = series.map((s) => { + if (s.boolean) { + return { + name: s.name, + type: 'line' as const, + step: 'end' as const, + yAxisIndex: 1, + data: s.points, + showSymbol: false, + lineStyle: { width: 1.5 }, + color: s.color, + }; + } + return { + name: s.name, + type: style === 'bars' ? ('bar' as const) : ('line' as const), + data: s.points, + smooth: false, + step: style === 'step' ? ('end' as const) : undefined, + stack: style === 'stacked' ? 'total' : undefined, + areaStyle: style === 'area' || style === 'stacked' ? { opacity: 0.2 } : undefined, + showSymbol: style !== 'bars' && s.points.length < 100, + symbol: 'circle', + symbolSize: 3, + lineStyle: { width: 1.5 }, + color: s.color, + }; + }); + + return { + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, + legend: { data: series.map((s) => s.name), type: 'scroll', bottom: 0 }, + grid: { top: 40, right: hasBoolean ? 60 : 40, bottom: 60, left: 60 }, + xAxis: { + type: 'time', + min: new Date(range.start).getTime(), + max: new Date(range.end).getTime(), + }, + yAxis: [ + // A log axis cannot start from the data + { type: logScale ? 'log' : 'value', scale: !logScale }, + ...(hasBoolean + ? [ + { + type: 'value', + min: 0, + max: 1, + interval: 1, + splitLine: { show: false }, + axisLabel: { formatter: (value: number) => (value ? 'true' : 'false') }, + }, + ] + : []), + ], + dataZoom: [ + { type: 'inside', start: 0, end: 100 }, + { type: 'slider', start: 0, end: 100, bottom: 30, height: 20 }, + ], + toolbox: { feature: { saveAsImage: {}, dataZoom: {}, restore: {} } }, + series: lines, + }; +} diff --git a/frontend/src/lib/chartStep.ts b/frontend/src/lib/chartStep.ts index 5b488cac..dfbba25f 100644 --- a/frontend/src/lib/chartStep.ts +++ b/frontend/src/lib/chartStep.ts @@ -58,3 +58,7 @@ export function resolveStep(choice: string, start: string, end: string): string if (choice === 'auto') return chartStep(start, end); return choice; } + +export function isBooleanType(type: string): boolean { + return type.toLowerCase() === 'boolean'; +} diff --git a/frontend/src/lib/timeRange.ts b/frontend/src/lib/timeRange.ts new file mode 100644 index 00000000..51115c21 --- /dev/null +++ b/frontend/src/lib/timeRange.ts @@ -0,0 +1,21 @@ +/** The ranges of the preset buttons, which are relative to now. */ +export const PRESETS = [ + { label: '15m', minutes: 15 }, + { label: '1h', minutes: 60 }, + { label: '6h', minutes: 360 }, + { label: '24h', minutes: 1440 }, + { label: '7d', minutes: 10080 }, + { label: '30d', minutes: 43200 }, +]; + +export const DEFAULT_RANGE = '1h'; + +/** The range of a preset ending now, or `undefined` for a label that is none. */ +export function rangeFor(label: string, now = new Date()): { start: string; end: string } | undefined { + const preset = PRESETS.find((p) => p.label === label); + if (!preset) return undefined; + return { + start: new Date(now.getTime() - preset.minutes * 60_000).toISOString(), + end: now.toISOString(), + }; +} diff --git a/frontend/src/lib/urlState.test.ts b/frontend/src/lib/urlState.test.ts new file mode 100644 index 00000000..3449c57c --- /dev/null +++ b/frontend/src/lib/urlState.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { fromSearchParams, toSearchParams } from './urlState'; +import type { UrlState } from './urlState'; + +const RANGE = { start: '2026-10-04T10:00:00.000Z', end: '2026-10-04T11:00:00.000Z' }; +const plain: UrlState = { + metric: null, + series: [], + relativeRange: '1h', + timeRange: RANGE, + step: 'auto', + aggregation: 'avg', + chartStyle: 'line', + logScale: false, +}; + +const parse = (query: string) => fromSearchParams(new URLSearchParams(query)); + +describe('toSearchParams', () => { + it('leaves the plain explorer with a plain address', () => { + expect(toSearchParams(plain).toString()).toBe(''); + }); + + it('writes what was chosen', () => { + const params = toSearchParams({ + ...plain, + metric: 'temperature value', + series: ['a-1', 'b-2'], + relativeRange: '24h', + step: '5m', + aggregation: 'max', + chartStyle: 'stacked', + logScale: true, + }); + expect(params.get('metric')).toBe('temperature value'); + expect(params.getAll('series')).toEqual(['a-1', 'b-2']); + expect(params.get('range')).toBe('24h'); + expect(params.get('step')).toBe('5m'); + expect(params.get('agg')).toBe('max'); + expect(params.get('style')).toBe('stacked'); + expect(params.get('log')).toBe('1'); + expect(params.has('from')).toBe(false); + }); + + it('writes the dates once they were typed', () => { + const params = toSearchParams({ ...plain, relativeRange: null }); + expect(params.get('from')).toBe(RANGE.start); + expect(params.get('to')).toBe(RANGE.end); + expect(params.has('range')).toBe(false); + }); +}); + +describe('fromSearchParams', () => { + it('reads back what was written', () => { + const state: UrlState = { + metric: 'cpu', + series: ['a', 'b'], + relativeRange: null, + timeRange: RANGE, + step: 'raw', + aggregation: 'last', + chartStyle: 'bars', + logScale: true, + }; + expect(fromSearchParams(toSearchParams(state))).toEqual(state); + }); + + it('reads a preset', () => { + expect(parse('range=7d')).toMatchObject({ relativeRange: '7d' }); + }); + + it('ignores what makes no sense', () => { + const state = parse('range=2y&step=7x&agg=median&style=pie&log=yes&from=nope&to=2026-10-04T11:00:00Z'); + expect(state).toEqual({ series: [] }); + }); + + it('ignores dates in the wrong order', () => { + expect(parse('from=2026-10-04T11:00:00Z&to=2026-10-04T10:00:00Z')).toEqual({ series: [] }); + }); + + it('asks for a series once', () => { + expect(parse('series=a&series=a&series=b&series=').series).toEqual(['a', 'b']); + }); +}); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts new file mode 100644 index 00000000..ee2fdb46 --- /dev/null +++ b/frontend/src/lib/urlState.ts @@ -0,0 +1,71 @@ +import { AGGREGATIONS, FIXED_STEPS } from './chartStep'; +import type { Aggregation } from './chartStep'; +import { CHART_STYLES } from './chartOption'; +import type { ChartStyle } from './chartOption'; +import { DEFAULT_RANGE, PRESETS } from './timeRange'; + +/** What the address says, and what the explorer shows from it. */ +export interface UrlState { + metric: string | null; + series: string[]; + /** A preset such as `24h`, or `null` when the range is `timeRange` */ + relativeRange: string | null; + timeRange: { start: string; end: string }; + step: string; + aggregation: Aggregation; + chartStyle: ChartStyle; + logScale: boolean; +} + +/** The address of a state. What is by default is left out: the plain address is the plain explorer. */ +export function toSearchParams(state: UrlState): URLSearchParams { + const params = new URLSearchParams(); + if (state.metric) params.set('metric', state.metric); + for (const uuid of state.series) params.append('series', uuid); + if (state.relativeRange === null) { + params.set('from', state.timeRange.start); + params.set('to', state.timeRange.end); + } else if (state.relativeRange !== DEFAULT_RANGE) { + params.set('range', state.relativeRange); + } + if (state.step !== 'auto') params.set('step', state.step); + if (state.aggregation !== 'avg') params.set('agg', state.aggregation); + if (state.chartStyle !== 'line') params.set('style', state.chartStyle); + if (state.logScale) params.set('log', '1'); + return params; +} + +function isDate(value: string | null): value is string { + return value !== null && !Number.isNaN(Date.parse(value)); +} + +/** The state of an address. What is wrong or unknown is ignored: an address may be old or typed. */ +export function fromSearchParams(params: URLSearchParams): Partial { + const state: Partial = {}; + + const metric = params.get('metric'); + if (metric) state.metric = metric; + state.series = [...new Set(params.getAll('series').filter(Boolean))]; + + const from = params.get('from'); + const to = params.get('to'); + const range = params.get('range'); + if (isDate(from) && isDate(to) && Date.parse(from) < Date.parse(to)) { + state.relativeRange = null; + state.timeRange = { start: new Date(from).toISOString(), end: new Date(to).toISOString() }; + } else if (range && PRESETS.some((p) => p.label === range)) { + state.relativeRange = range; + } + + const step = params.get('step'); + if (step === 'raw' || (step && FIXED_STEPS.includes(step))) state.step = step; + const aggregation = params.get('agg'); + if (aggregation && (AGGREGATIONS as readonly string[]).includes(aggregation)) { + state.aggregation = aggregation as Aggregation; + } + const style = params.get('style'); + if (style && (CHART_STYLES as string[]).includes(style)) state.chartStyle = style as ChartStyle; + if (params.get('log') === '1') state.logScale = true; + + return state; +} diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 7f3d04d1..9fb653c6 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -1,12 +1,15 @@ +import { ChartOptions } from '../components/ChartOptions'; import { MetricsTable } from '../components/MetricsTable'; import { SeriesTable } from '../components/SeriesTable'; import { TimeSeriesChart } from '../components/TimeSeriesChart'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; +import { useUrlState } from '../hooks/useUrlState'; import { useSelectionStore } from '../stores/useSelectionStore'; export function ExplorerPage() { const { selectedMetric, selectedSeries, clearSelectedSeries } = useSelectionStore(); + useUrlState(); return (
@@ -19,6 +22,7 @@ export function ExplorerPage() { {selectedSeries.length} series )} +
diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index 0831db57..be222b2e 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -1,7 +1,9 @@ import { create } from 'zustand'; import type { Aggregation } from '../lib/chartStep'; +import type { ChartStyle } from '../lib/chartOption'; +import { DEFAULT_RANGE, rangeFor } from '../lib/timeRange'; -interface SelectedSeries { +export interface SelectedSeries { uuid: string; name: string; labels: Record; @@ -15,6 +17,10 @@ interface SelectionState { start: string; end: string; }; + /** The preset the range comes from (`24h`), `null` once dates were typed */ + relativeRange: string | null; + chartStyle: ChartStyle; + logScale: boolean; /** `auto` (the range decides), `raw`, or a duration such as `5m` */ step: string; aggregation: Aggregation; @@ -23,6 +29,10 @@ interface SelectionState { toggleSeries: (series: SelectedSeries) => void; clearSelectedSeries: () => void; setTimeRange: (start: string, end: string) => void; + /** A preset: the range ending now */ + setRelativeRange: (label: string) => void; + setChartStyle: (style: ChartStyle) => void; + setLogScale: (log: boolean) => void; setLabelFilter: (filter: string) => void; setStep: (step: string) => void; setAggregation: (aggregation: Aggregation) => void; @@ -41,6 +51,9 @@ export const useSelectionStore = create((set) => ({ selectedMetric: null, selectedSeries: [], timeRange: defaultTimeRange(), + relativeRange: DEFAULT_RANGE, + chartStyle: 'line', + logScale: false, labelFilter: '', step: 'auto', aggregation: 'avg', @@ -65,7 +78,17 @@ export const useSelectionStore = create((set) => ({ clearSelectedSeries: () => set({ selectedSeries: [] }), - setTimeRange: (start, end) => set({ timeRange: { start, end } }), + setTimeRange: (start, end) => + set({ timeRange: { start, end }, relativeRange: null }), + + setRelativeRange: (label) => { + const timeRange = rangeFor(label); + if (timeRange) set({ timeRange, relativeRange: label }); + }, + + setChartStyle: (chartStyle) => set({ chartStyle }), + + setLogScale: (logScale) => set({ logScale }), setLabelFilter: (filter) => set({ labelFilter: filter }), diff --git a/frontend/src/test/setup.tsx b/frontend/src/test/setup.tsx index 1e84b11e..17622c06 100644 --- a/frontend/src/test/setup.tsx +++ b/frontend/src/test/setup.tsx @@ -5,7 +5,11 @@ vi.mock('../components/EChart', () => ({ default: (props: { option: unknown }) => { const series = (props.option as { series?: unknown[] })?.series; return ( -
+
Chart
); diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md index d617e76c..34824a5d 100644 --- a/ideas/frontend-next-steps.md +++ b/ideas/frontend-next-steps.md @@ -2,13 +2,14 @@ What was left out of `done/frontend-good-enough.md` on purpose, most useful first. -- **Selection in the URL.** Metric, series and time range as query parameters, so that a chart can be shared - or reloaded. Small with the router already in place. -- **Non-numeric series.** Strings, booleans and locations are listed but not drawn. A table of the latest - values (`/series/{uuid}/last`) would show them. -- **Refresh.** A live "last 15 minutes" that follows the clock, with an auto-refresh interval. +- **Strings and locations.** Listed but not drawn. A table of the values over the range, or the latest value + (`/series/{uuid}/last`), would show strings; locations want a map. - **Several instances, one UI.** The API base is `VITE_SENSAPP_API_URL` at build time. A runtime setting would allow one build against any instance; only useful if the UI is ever served apart from SensApp. - **TypeScript 7.** Held at 5.9 until `typescript-eslint` supports it (peer range `<6.1`). - The DCAT types of `src/hooks/useMetrics.ts` and `useSeries.ts` are written by hand because the OpenAPI document types the catalogs as a plain string. Typing them in the server would remove the hand-written copy. +- **Expression box (PromQL).** Decided against for now: the series browser with step and aggregation covers one + series at a time, and the box would only add combining series (`avg by (room)`, through `/api/v1/query`). + Revisit when someone asks for that. +- Not planned: dashboards, alerting, a query builder (Grafana's job). Auto-refresh was declined too. From d84f14196e8726c6b2b698f6473843af1fcb7497 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:06:40 +0200 Subject: [PATCH 10/53] =?UTF-8?q?test:=20=E2=9C=85=20the=20freshest-rows?= =?UTF-8?q?=20test=20no=20longer=20depends=20on=20the=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It wrote its data up to the current ten seconds, and failed when that was a multiple of ten minutes (or the ten seconds before): the row of the coarse archive that ends with the last update is complete, and holds the average of its ten minutes, as it should. The end is now in the middle of an interval, and the boundary case has a test of its own. Co-Authored-By: Claude Sonnet 5.5 --- tests/integration/rrdcached_integration.rs | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/integration/rrdcached_integration.rs b/tests/integration/rrdcached_integration.rs index e7d63ca4..43ba738b 100644 --- a/tests/integration/rrdcached_integration.rs +++ b/tests/integration/rrdcached_integration.rs @@ -668,8 +668,10 @@ mod rrdcached_tests { // archive of ten seconds let db = connect().await?; let storage = db.storage(); + // The end is in the middle of a ten minutes interval, wherever the clock is: the + // outcome depends on it (see the next test). The clock does not matter to the daemon. + let end = now() - now() % 600 - 300; let sensor = new_sensor(); - let end = now(); // Twenty-five minutes of data: the rows of ten minutes before the last one are complete let points = ramp(end - 1500, 10, 151, 1.0); publish(&storage, &sensor, &points).await?; @@ -682,6 +684,27 @@ mod rrdcached_tests { Ok(()) } + #[tokio::test] + #[serial] + async fn a_last_update_at_the_end_of_a_ten_minutes_interval_is_in_the_coarse_row() -> Result<()> + { + // The row of ten minutes that ends with the last update is complete: there is nothing + // fresher to read, and its value is the average of the ten minutes, not the last sample + let db = connect().await?; + let storage = db.storage(); + let end = now() - now() % 600 - 600; + let sensor = new_sensor(); + let points = ramp(end - 1500, 10, 151, 1.0); + publish(&storage, &sensor, &points).await?; + + let rows = read(&storage, &sensor, end - 3 * 86400, end).await?; + let last = rows.last().unwrap(); + assert_eq!(last.0, end, "{rows:?}"); + // 91 to 150: the samples of the interval (end - 600, end] + assert_eq!(last.1, (91..=150).sum::() as f64 / 60.0, "{rows:?}"); + Ok(()) + } + #[tokio::test] #[serial] async fn the_munin_preset_keeps_five_minutes_rows() -> Result<()> { From 1d65fde8429c627406e9f0a676c0f5e46a6b7328 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:15:42 +0200 Subject: [PATCH 11/53] =?UTF-8?q?fix:=20=F0=9F=90=9B=20remote=20read=20hin?= =?UTF-8?q?ts:=20only=20aggregate=20when=20Prometheus=20gets=20the=20right?= =?UTF-8?q?=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus evaluates the query again on the samples of a remote read, so buckets are only a valid answer for the mergeable *_over_time functions with a range that is a whole number of steps. Count, the aggregation operators (sum, avg, ...) and windows shorter than the step answered wrongly (sum(x) gave 36004 instead of 576, count_over_time gave 1 instead of 60); they now read the raw samples. A live test compares 20 queries evaluated by a real Prometheus with the raw samples; the harness now runs Prometheus v3.8.0 (PROMETHEUS_IMAGE overrides it). Co-Authored-By: Claude Sonnet 5.5 --- src/http/prometheus_read.rs | 154 +++++++++++++++++++---------- tests/prometheus_live/README.md | 2 +- tests/prometheus_live/run.sh | 2 +- tests/prometheus_live/test_live.py | 110 +++++++++++++++++++++ 4 files changed, 215 insertions(+), 53 deletions(-) diff --git a/src/http/prometheus_read.rs b/src/http/prometheus_read.rs index 8b38f26b..c813d9da 100644 --- a/src/http/prometheus_read.rs +++ b/src/http/prometheus_read.rs @@ -24,24 +24,34 @@ use std::time::Instant; use tokio_util::bytes::Bytes; use tracing::{debug, info, warn}; +/// Prometheus does not trust a remote read to have answered the function of the hint: it +/// evaluates the query again on the samples it gets back. A hint is therefore only answered with +/// buckets when the second evaluation gives the result of the raw samples, which is the case of +/// the `*_over_time` functions that can be merged (min, max, sum, first, last, and the average +/// of buckets, exact when the buckets hold the same number of samples). `count_over_time` would +/// count the buckets, and the aggregation operators (`sum`, `avg`, ...) ask for the value of each +/// series at the evaluation time, not for an aggregate of the hour that follows it. fn aggregation_from_read_hints(hints: &ReadHints) -> Option { - match hints.func.trim().to_ascii_lowercase().as_str() { - "avg_over_time" | "avg" => Some(Aggregation::Avg), - "min_over_time" | "min" => Some(Aggregation::Min), - "max_over_time" | "max" => Some(Aggregation::Max), - "sum_over_time" | "sum" => Some(Aggregation::Sum), - "count_over_time" | "count" => Some(Aggregation::Count), - "first_over_time" | "first" => Some(Aggregation::First), - "last_over_time" | "last" => Some(Aggregation::Last), + match hints.func.trim() { + "avg_over_time" => Some(Aggregation::Avg), + "min_over_time" => Some(Aggregation::Min), + "max_over_time" => Some(Aggregation::Max), + "sum_over_time" => Some(Aggregation::Sum), + "first_over_time" => Some(Aggregation::First), + "last_over_time" => Some(Aggregation::Last), _ => None, } } +/// `range_ms` is the width of the `[1h]` of the query, `step_ms` the distance between two +/// evaluations. Prometheus starts the window at the end of the first range, so buckets of one +/// step that start there fit the windows of the evaluations when the range is a whole number of +/// steps. A shorter range would be answered with a bucket wider than the window it asks for. fn query_options_from_read_hints(query: &Query) -> Option { let hints = query.hints.as_ref()?; let aggregation = aggregation_from_read_hints(hints)?; - if hints.step_ms <= 0 { + if hints.step_ms <= 0 || hints.range_ms <= 0 || hints.range_ms % hints.step_ms != 0 { return None; } @@ -601,60 +611,102 @@ mod tests { assert!(verify_read_headers(&headers).is_err()); } - #[test] - fn test_aggregation_from_read_hints() { - let hints = ReadHints { - step_ms: 60_000, - func: "avg_over_time".to_string(), + fn hints(func: &str, step_ms: i64, range_ms: i64) -> ReadHints { + ReadHints { + step_ms, + func: func.to_string(), start_ms: 0, end_ms: 0, grouping: vec![], by: false, - range_ms: 60_000, - }; - - assert_eq!(aggregation_from_read_hints(&hints), Some(Aggregation::Avg)); - - let unknown = ReadHints { - func: "rate".to_string(), - ..hints - }; - assert_eq!(aggregation_from_read_hints(&unknown), None); + range_ms, + } } - #[test] - fn test_query_options_from_read_hints_requires_supported_func() { - let query = Query { + fn query_with(hints: ReadHints) -> Query { + Query { start_timestamp_ms: 1_000, end_timestamp_ms: 5_000, matchers: vec![], - hints: Some(ReadHints { - step_ms: 2_000, - func: "max_over_time".to_string(), - start_ms: 1_000, - end_ms: 5_000, - grouping: vec![], - by: false, - range_ms: 2_000, - }), - }; + hints: Some(hints), + } + } + + #[test] + fn test_aggregation_from_read_hints() { + for (func, expected) in [ + ("avg_over_time", Aggregation::Avg), + ("min_over_time", Aggregation::Min), + ("max_over_time", Aggregation::Max), + ("sum_over_time", Aggregation::Sum), + ("first_over_time", Aggregation::First), + ("last_over_time", Aggregation::Last), + ] { + assert_eq!( + aggregation_from_read_hints(&hints(func, 60_000, 60_000)), + Some(expected), + "{func}" + ); + } + } + + #[test] + fn test_hints_that_buckets_would_answer_wrongly_are_not_aggregated() { + // Prometheus counts the buckets, evaluates the aggregation operators at the evaluation + // time and cannot use buckets for a rate. + for func in [ + "count_over_time", + "count", + "sum", + "avg", + "min", + "max", + "rate", + "", + ] { + assert_eq!( + aggregation_from_read_hints(&hints(func, 60_000, 60_000)), + None, + "{func:?}" + ); + } + } - let options = query_options_from_read_hints(&query).unwrap(); + #[test] + fn test_query_options_from_read_hints_follow_the_range_and_the_step() { + let options = + query_options_from_read_hints(&query_with(hints("max_over_time", 2_000, 2_000))) + .unwrap(); assert_eq!(options.step_ms, Some(2_000)); assert_eq!(options.aggregation, Some(Aggregation::Max)); - let unsupported = Query { - hints: Some(ReadHints { - step_ms: 2_000, - func: "rate".to_string(), - start_ms: 1_000, - end_ms: 5_000, - grouping: vec![], - by: false, - range_ms: 2_000, - }), - ..query - }; - assert!(query_options_from_read_hints(&unsupported).is_none()); + // Several steps in a range keep the buckets inside the windows. + assert!( + query_options_from_read_hints(&query_with(hints("max_over_time", 2_000, 6_000))) + .is_some() + ); + + // A range shorter than the step, a range that is not a whole number of steps, an instant + // selector (no range) or no step cannot be answered with buckets. + for (step_ms, range_ms) in [(2_000, 1_000), (2_000, 3_000), (2_000, 0), (0, 2_000)] { + assert!( + query_options_from_read_hints(&query_with(hints( + "max_over_time", + step_ms, + range_ms + ))) + .is_none(), + "step {step_ms} range {range_ms}" + ); + } + + assert!(query_options_from_read_hints(&query_with(hints("rate", 2_000, 2_000))).is_none()); + assert!( + query_options_from_read_hints(&Query { + hints: None, + ..query_with(hints("max_over_time", 2_000, 2_000)) + }) + .is_none() + ); } } diff --git a/tests/prometheus_live/README.md b/tests/prometheus_live/README.md index 4fd1405e..835f1d8f 100644 --- a/tests/prometheus_live/README.md +++ b/tests/prometheus_live/README.md @@ -1,6 +1,6 @@ # Live Prometheus compatibility test -This test runs a real Prometheus v2.55.1 against SensApp backed by PostgreSQL. It checks remote write and remote read independently. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. +This test runs a real Prometheus (v3.8.0, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. Start a local PostgreSQL server with an empty database, then run: diff --git a/tests/prometheus_live/run.sh b/tests/prometheus_live/run.sh index 1da4c3f6..3d9a21cf 100644 --- a/tests/prometheus_live/run.sh +++ b/tests/prometheus_live/run.sh @@ -39,7 +39,7 @@ docker run --detach \ --add-host host.docker.internal:host-gateway \ --publish 127.0.0.1:9099:9090 \ --volume "$repo_root/tests/prometheus_live/prometheus.yml:/etc/prometheus/prometheus.yml:ro" \ - prom/prometheus:v2.55.1 \ + "${PROMETHEUS_IMAGE:-prom/prometheus:v3.8.0}" \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus diff --git a/tests/prometheus_live/test_live.py b/tests/prometheus_live/test_live.py index b9c8eb33..f5624fef 100644 --- a/tests/prometheus_live/test_live.py +++ b/tests/prometheus_live/test_live.py @@ -102,11 +102,121 @@ def queried_from_prometheus() -> tuple[bool, str]: wait_for("Prometheus remote read returned the SensApp-only probe", queried_from_prometheus) +HOUR = 3600 +MINUTES = 6 * 60 + + +def hint_value(series: str, minute: int) -> float: + """Values that move all the time, so a wrong window or bucket changes the answer.""" + if series == "a": + return ((minute * 7919) % 1000) / 10.0 + return 500.0 + ((minute * 104729) % 1000) / 10.0 + + +def query_range(expression: str, start: int, end: int, step: int) -> dict[int, float]: + params = urllib.parse.urlencode( + {"query": expression, "start": start, "end": end, "step": step} + ) + status, body = request(PROMETHEUS, "/api/v1/query_range?" + params) + if status != 200: + raise AssertionError(f"{expression}: Prometheus HTTP {status}: {body[:300]}") + result = json.loads(body)["data"]["result"] + if len(result) != 1: + raise AssertionError(f"{expression}: expected one series, got {result}") + return {int(float(t)): float(v) for t, v in result[0]["values"]} + + +def test_remote_read_hints() -> None: + """Prometheus evaluates the query itself on what SensApp returns, so what SensApp returns + for a hint (`func`, `step`, `range`) must give the same answer as the raw samples. + + Prometheus asks for the window (t - range, t] of every evaluation time t, and SensApp + answers with one bucket per `step`: this is exact when the range is a multiple of the step + for the functions that can be merged (min, max, sum, last), and the average is then an + average of equal-sized buckets. Everything else must be answered with the raw samples. + """ + tag = uuid.uuid4().hex[:8] + t0 = int(time.time()) // HOUR * HOUR - 7 * HOUR + names = {s: f"hints_{tag}_{s}" for s in "ab"} + payload = [ + {"n": names[s], "v": hint_value(s, m), "t": t0 + 60 * m} + for s in "ab" + for m in range(MINUTES) + ] + status, body = request(SENSAPP, "/publish", json.dumps(payload).encode()) + if status != 200: + raise AssertionError(f"Could not seed SensApp: HTTP {status}: {body[:300]}") + + def window(series: str, t: int, seconds: int) -> list[float]: + return [ + hint_value(series, m) + for m in range(MINUTES) + if t - seconds < t0 + 60 * m <= t + ] + + def mean(values: list[float]) -> float: + return sum(values) / len(values) + + def across(function): + # What an aggregation operator sees: the last sample of each series within 5 minutes. + return lambda t: function([window(s, t, 300)[-1] for s in "ab"]) + + def over(function, seconds): + return lambda t: function(window("a", t, seconds)) + + selector = f'{{__name__=~"hints_{tag}_.*"}}' + one = names["a"] + cases = [ + # (expression, step, expected value at the evaluation time, answered in the database?) + (f"avg_over_time({one}[1h])", HOUR, over(mean, HOUR), True), + (f"min_over_time({one}[1h])", HOUR, over(min, HOUR), True), + (f"max_over_time({one}[1h])", HOUR, over(max, HOUR), True), + (f"sum_over_time({one}[1h])", HOUR, over(sum, HOUR), True), + (f"last_over_time({one}[1h])", HOUR, over(lambda w: w[-1], HOUR), True), + (f"avg_over_time({one}[15m])", 900, over(mean, 900), True), + (f"max_over_time({one}[15m])", 900, over(max, 900), True), + # A range that is several steps: the buckets still fit the windows. + (f"max_over_time({one}[2h])", HOUR, over(max, 2 * HOUR), True), + (f"min_over_time({one}[2h])", HOUR, over(min, 2 * HOUR), True), + (f"sum_over_time({one}[2h])", HOUR, over(sum, 2 * HOUR), True), + (f"avg_over_time({one}[2h])", HOUR, over(mean, 2 * HOUR), True), + # Not answered with buckets, or the answer would be wrong: + # a window shorter than the step, a count of samples, aggregations across series. + (f"avg_over_time({one}[30m])", HOUR, over(mean, 1800), False), + (f"max_over_time({one}[10m])", 900, over(max, 600), False), + (f"count_over_time({one}[1h])", HOUR, over(len, HOUR), False), + (f"sum({selector})", HOUR, across(sum), False), + (f"avg({selector})", HOUR, across(mean), False), + (f"min({selector})", HOUR, across(min), False), + (f"max({selector})", HOUR, across(max), False), + (f"count({selector})", HOUR, across(len), False), + (one, HOUR, lambda t: window("a", t, 300)[-1], False), + ] + + failures = [] + for expression, step, expected, _in_database in cases: + # The first evaluation time is chosen so that every window is full of samples. + start, end = t0 + 2 * HOUR, t0 + 5 * HOUR + got = query_range(expression, start, end, step) + for t in range(start, end + 1, step): + want = expected(t) + if t not in got or abs(got[t] - want) > 1e-9 * max(1.0, abs(want)): + failures.append( + f"{expression} step={step}s at T0+{(t - t0) / HOUR:.2f}h: " + f"Prometheus says {got.get(t)}, the raw samples say {want}" + ) + break + if failures: + raise AssertionError("remote read hints give wrong answers:\n " + "\n ".join(failures)) + print(f"PASS remote read hints give the answer of the raw samples ({len(cases)} queries)", flush=True) + + def main() -> None: wait_for("SensApp readiness", lambda: ready(SENSAPP)) wait_for("Prometheus readiness", lambda: ready(PROMETHEUS)) test_remote_write() test_remote_read() + test_remote_read_hints() if __name__ == "__main__": From 1358322552667bf171529379885a42f1d3ccdc8a Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:15:43 +0200 Subject: [PATCH 12/53] =?UTF-8?q?docs:=20=F0=9F=93=9D=20what=20the=20remot?= =?UTF-8?q?e=20read=20hints=20are=20answered=20with,=20and=20why?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/DATAMODEL.md | 15 ++++++++++++ done/remote-read-hints-semantics.md | 37 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 done/remote-read-hints-semantics.md diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md index 726c6b60..87c00af8 100644 --- a/docs/DATAMODEL.md +++ b/docs/DATAMODEL.md @@ -209,3 +209,18 @@ In the current version, the geolocalised data doesn't really mind the coordinate ## TimeStamps are in microseconds We use microsecond timestamps, as it provides a good compromise between precision and storage size. Some time-series database go down to nanoseconds but then the minimum and maximum timestamps are too close in times using 64 bits integers. It should be possible to have historical data and prediction data in SensApp. We haven't identified use cases that would require nanosecond precision in our research. People with such use cases should consider patching SensApp or using another solution. + +## Prometheus remote read and its hints + +A remote read query carries the hints of the PromQL query that asks for it: the function (`avg_over_time`), the `step` between two evaluations and the `range` of the `[1h]`. Prometheus does not trust the answer to have applied them: it evaluates the query again on the samples it receives. SensApp therefore only aggregates in the database (one bucket per `step`) when this second evaluation gives the result of the raw samples, and sends the raw samples otherwise, within the selector limits of [HTTP_LIMITS.md](HTTP_LIMITS.md). + +| Hint | Answered with | +| --- | --- | +| `avg_over_time`, `min_over_time`, `max_over_time`, `sum_over_time`, `first_over_time`, `last_over_time`, with a `range` that is a whole number of steps | buckets of one `step`, which start where the window of the first evaluation starts | +| `count_over_time` | raw samples (Prometheus would count the buckets) | +| a `range` shorter than the `step`, or not a whole number of steps | raw samples (the bucket would be wider than the window) | +| `sum`, `avg`, `min`, `max`, `count` across series, `rate`, plain selectors | raw samples (the value at the evaluation time is needed, not an aggregate of the following step) | + +When the range is several steps, `min`, `max`, `sum`, `first` and `last` are exact. `avg_over_time` averages the averages of the buckets of its window: exact when they hold as many samples each, an approximation otherwise (a series with gaps, the edges of the data). This is a compromise, the price of sending a year of data at a one hour step as 8,760 values instead of all the samples. + +This was measured with a real Prometheus 3.x (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; the windows of Prometheus 2 include their start, which was not checked. diff --git a/done/remote-read-hints-semantics.md b/done/remote-read-hints-semantics.md new file mode 100644 index 00000000..5b3c5491 --- /dev/null +++ b/done/remote-read-hints-semantics.md @@ -0,0 +1,37 @@ +# Prometheus remote read hints: only aggregate when Prometheus gets the right answer + +## Question + +When Prometheus asks for `avg_over_time(x[1h])` over a year with a one hour step, does SensApp send all the +samples, or aggregate in the database? + +## Findings (Prometheus v3.8.0, SensApp on PostgreSQL, 6 hours of one sample a minute) + +SensApp aggregated in the database (one bucket per `step`, bulk on every SQL backend and BigQuery). But +Prometheus evaluates the query again on what it receives, and the buckets only gave the right answer in +some cases. The buckets start at `query.start_timestamp_ms`, which Prometheus 3 sets to the start of the +first window (`t - range + 1 ms`), so for a range equal to the step a bucket is exactly one window: the +shift of one bucket I feared does not happen. + +| Query (step 1h unless said) | Before | Why | +| --- | --- | --- | +| `avg/min/max/sum/last_over_time(x[1h])` | right | one bucket per window | +| `count_over_time(x[1h])` | 1 instead of 60 | Prometheus counts the buckets | +| `avg_over_time(x[30m])`, `max_over_time(x[10m])` at 15m | wrong | the bucket is wider than the window (`range_ms` < `step_ms`) | +| `sum(x)`, `avg(x)`, `min(x)`, `max(x)` across series | `sum` 36004 instead of 576 | `func` is the operator, `range_ms` is 0: Prometheus wants the value at each step, not the aggregate of the following hour | +| `avg_over_time(x[2h])` | right when the buckets are full | average of averages: a compromise, kept | + +## Done + +- [x] `aggregation_from_read_hints` only maps the mergeable `*_over_time` functions (no `count`, no bare operator names). +- [x] `query_options_from_read_hints` requires `range_ms` to be a positive multiple of `step_ms`; otherwise raw samples. +- [x] Unit tests of both rules; the existing backend-generic hint test (range = step) still passes. +- [x] `tests/prometheus_live/test_live.py::test_remote_read_hints`: 20 queries through a real Prometheus compared with the raw samples (it + failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.8.0. +- [x] Documented in `docs/DATAMODEL.md`. + +## Not checked + +- Prometheus 2.x (the harness used to pin v2.55.1, not pulled locally): its windows include their start, so a bucket per step probably does not + fit. If 2.x matters, run `PROMETHEUS_IMAGE=prom/prometheus:v2.55.1 bash tests/prometheus_live/run.sh`. +- A big range at a small step with raw fallback is now an error above the sample limits (HTTP 400, "narrow the selector") instead of a wrong answer. From e23c5ec4653f40536749590a9f0e86097bb414c6 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:28:29 +0200 Subject: [PATCH 13/53] =?UTF-8?q?test:=20=E2=9C=85=20live=20Prometheus=20h?= =?UTF-8?q?arness=20runs=20v3.15.0,=20the=20latest=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/DATAMODEL.md | 2 +- done/remote-read-hints-semantics.md | 4 ++-- tests/prometheus_live/README.md | 2 +- tests/prometheus_live/run.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md index 87c00af8..6b3ba14d 100644 --- a/docs/DATAMODEL.md +++ b/docs/DATAMODEL.md @@ -223,4 +223,4 @@ A remote read query carries the hints of the PromQL query that asks for it: the When the range is several steps, `min`, `max`, `sum`, `first` and `last` are exact. `avg_over_time` averages the averages of the buckets of its window: exact when they hold as many samples each, an approximation otherwise (a series with gaps, the edges of the data). This is a compromise, the price of sending a year of data at a one hour step as 8,760 values instead of all the samples. -This was measured with a real Prometheus 3.x (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; the windows of Prometheus 2 include their start, which was not checked. +This was measured with real Prometheus 3.8 and 3.15 (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; the windows of Prometheus 2 include their start, which was not checked. diff --git a/done/remote-read-hints-semantics.md b/done/remote-read-hints-semantics.md index 5b3c5491..25df24e2 100644 --- a/done/remote-read-hints-semantics.md +++ b/done/remote-read-hints-semantics.md @@ -5,7 +5,7 @@ When Prometheus asks for `avg_over_time(x[1h])` over a year with a one hour step, does SensApp send all the samples, or aggregate in the database? -## Findings (Prometheus v3.8.0, SensApp on PostgreSQL, 6 hours of one sample a minute) +## Findings (Prometheus v3.8.0 then v3.15.0, SensApp on PostgreSQL, 6 hours of one sample a minute) SensApp aggregated in the database (one bucket per `step`, bulk on every SQL backend and BigQuery). But Prometheus evaluates the query again on what it receives, and the buckets only gave the right answer in @@ -27,7 +27,7 @@ shift of one bucket I feared does not happen. - [x] `query_options_from_read_hints` requires `range_ms` to be a positive multiple of `step_ms`; otherwise raw samples. - [x] Unit tests of both rules; the existing backend-generic hint test (range = step) still passes. - [x] `tests/prometheus_live/test_live.py::test_remote_read_hints`: 20 queries through a real Prometheus compared with the raw samples (it - failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.8.0. + failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.15.0. - [x] Documented in `docs/DATAMODEL.md`. ## Not checked diff --git a/tests/prometheus_live/README.md b/tests/prometheus_live/README.md index 835f1d8f..7877997f 100644 --- a/tests/prometheus_live/README.md +++ b/tests/prometheus_live/README.md @@ -1,6 +1,6 @@ # Live Prometheus compatibility test -This test runs a real Prometheus (v3.8.0, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. +This test runs a real Prometheus (v3.15.0, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. Start a local PostgreSQL server with an empty database, then run: diff --git a/tests/prometheus_live/run.sh b/tests/prometheus_live/run.sh index 3d9a21cf..b4c91dc4 100644 --- a/tests/prometheus_live/run.sh +++ b/tests/prometheus_live/run.sh @@ -39,7 +39,7 @@ docker run --detach \ --add-host host.docker.internal:host-gateway \ --publish 127.0.0.1:9099:9090 \ --volume "$repo_root/tests/prometheus_live/prometheus.yml:/etc/prometheus/prometheus.yml:ro" \ - "${PROMETHEUS_IMAGE:-prom/prometheus:v3.8.0}" \ + "${PROMETHEUS_IMAGE:-prom/prometheus:v3.15.0}" \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus From 33b168c8c79c2b8c875baf1dce783ac8cb5bcb54 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:31:12 +0200 Subject: [PATCH 14/53] =?UTF-8?q?perf:=20=E2=9A=A1=EF=B8=8F=20frontend:=20?= =?UTF-8?q?one=20variable=20Roboto=20file=20instead=20of=20three=20weights?= =?UTF-8?q?=20in=20two=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- frontend/src/index.css | 14 ++++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 77e1f247..699f760d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@fontsource/roboto": "^5.2.10", + "@fontsource-variable/roboto": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", @@ -693,10 +693,10 @@ } } }, - "node_modules/@fontsource/roboto": { + "node_modules/@fontsource-variable/roboto": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz", - "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz", + "integrity": "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" diff --git a/frontend/package.json b/frontend/package.json index fde14d50..bf884738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "openapi-ts": "openapi-ts" }, "dependencies": { - "@fontsource/roboto": "^5.2.10", + "@fontsource-variable/roboto": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", diff --git a/frontend/src/index.css b/frontend/src/index.css index c2980562..6e517c62 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,13 @@ @import "tailwindcss"; -@import "@fontsource/roboto/latin-400.css"; -@import "@fontsource/roboto/latin-500.css"; -@import "@fontsource/roboto/latin-700.css"; + +/* One variable file for every weight, latin only (the UI is in English). */ +@font-face { + font-family: "Roboto Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2") format("woff2-variations"); +} @plugin "daisyui" { themes: light --default, dark --prefersdark; @@ -25,7 +31,7 @@ } @theme { - --font-sans: Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-sans: "Roboto Variable", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } /* The page scrolls on small screens. From lg on it is one screen, and the panels scroll. */ From c75189e47472e9afd4ab502230ffe605b2dab2ce Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 10:31:24 +0200 Subject: [PATCH 15/53] =?UTF-8?q?test:=20=E2=9C=85=20live=20Prometheus=20h?= =?UTF-8?q?arness=20defaults=20to=20v3.13.4,=20the=20LTS=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus 2 is not supported. v3.15.0 (latest) and v3.8.0 pass too. Co-Authored-By: Claude Sonnet 5.5 --- docs/DATAMODEL.md | 2 +- done/remote-read-hints-semantics.md | 7 +++---- tests/prometheus_live/README.md | 2 +- tests/prometheus_live/run.sh | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md index 6b3ba14d..5b275fb5 100644 --- a/docs/DATAMODEL.md +++ b/docs/DATAMODEL.md @@ -223,4 +223,4 @@ A remote read query carries the hints of the PromQL query that asks for it: the When the range is several steps, `min`, `max`, `sum`, `first` and `last` are exact. `avg_over_time` averages the averages of the buckets of its window: exact when they hold as many samples each, an approximation otherwise (a series with gaps, the edges of the data). This is a compromise, the price of sending a year of data at a one hour step as 8,760 values instead of all the samples. -This was measured with real Prometheus 3.8 and 3.15 (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; the windows of Prometheus 2 include their start, which was not checked. +This was measured with real Prometheus 3.8, 3.13 (LTS) and 3.15 (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; Prometheus 2 is not supported. diff --git a/done/remote-read-hints-semantics.md b/done/remote-read-hints-semantics.md index 25df24e2..ea2c7e64 100644 --- a/done/remote-read-hints-semantics.md +++ b/done/remote-read-hints-semantics.md @@ -5,7 +5,7 @@ When Prometheus asks for `avg_over_time(x[1h])` over a year with a one hour step, does SensApp send all the samples, or aggregate in the database? -## Findings (Prometheus v3.8.0 then v3.15.0, SensApp on PostgreSQL, 6 hours of one sample a minute) +## Findings (Prometheus v3.8.0, v3.13.4 and v3.15.0, SensApp on PostgreSQL, 6 hours of one sample a minute) SensApp aggregated in the database (one bucket per `step`, bulk on every SQL backend and BigQuery). But Prometheus evaluates the query again on what it receives, and the buckets only gave the right answer in @@ -27,11 +27,10 @@ shift of one bucket I feared does not happen. - [x] `query_options_from_read_hints` requires `range_ms` to be a positive multiple of `step_ms`; otherwise raw samples. - [x] Unit tests of both rules; the existing backend-generic hint test (range = step) still passes. - [x] `tests/prometheus_live/test_live.py::test_remote_read_hints`: 20 queries through a real Prometheus compared with the raw samples (it - failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.15.0. + failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.13.4 (the LTS line, supported until July 2027; v3.15.0, the latest release, passes too). - [x] Documented in `docs/DATAMODEL.md`. ## Not checked -- Prometheus 2.x (the harness used to pin v2.55.1, not pulled locally): its windows include their start, so a bucket per step probably does not - fit. If 2.x matters, run `PROMETHEUS_IMAGE=prom/prometheus:v2.55.1 bash tests/prometheus_live/run.sh`. +- Prometheus 2.x: not supported or tested (the harness runs the 3.x LTS line). - A big range at a small step with raw fallback is now an error above the sample limits (HTTP 400, "narrow the selector") instead of a wrong answer. diff --git a/tests/prometheus_live/README.md b/tests/prometheus_live/README.md index 7877997f..55d93047 100644 --- a/tests/prometheus_live/README.md +++ b/tests/prometheus_live/README.md @@ -1,6 +1,6 @@ # Live Prometheus compatibility test -This test runs a real Prometheus (v3.15.0, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. +This test runs a real Prometheus (v3.13.4, the LTS line, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. Start a local PostgreSQL server with an empty database, then run: diff --git a/tests/prometheus_live/run.sh b/tests/prometheus_live/run.sh index b4c91dc4..41f7930c 100644 --- a/tests/prometheus_live/run.sh +++ b/tests/prometheus_live/run.sh @@ -39,7 +39,7 @@ docker run --detach \ --add-host host.docker.internal:host-gateway \ --publish 127.0.0.1:9099:9090 \ --volume "$repo_root/tests/prometheus_live/prometheus.yml:/etc/prometheus/prometheus.yml:ro" \ - "${PROMETHEUS_IMAGE:-prom/prometheus:v3.15.0}" \ + "${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.4}" \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus From a3d95f018f4058d4ea48f4f55a780adf1e1b9418 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:00:52 +0200 Subject: [PATCH 16/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20no=20seri?= =?UTF-8?q?es=20count=20in=20the=20chart=20header,=20no=20health=20badge,?= =?UTF-8?q?=20bounded=20metric=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series list already says how many are selected, and a server that is down shows the error of the request. A metric name is truncated at a fair width (the full name on hover), and a name that exists with two types is two rows with their own keys. Co-Authored-By: Claude Sonnet 5.5 --- current_tasks/frontend-review-round-3.md | 33 ++++++++++++ frontend/src/App.test.tsx | 6 +-- frontend/src/auth.test.tsx | 1 - frontend/src/components/HealthBadge.test.tsx | 53 ------------------- frontend/src/components/HealthBadge.tsx | 29 ---------- frontend/src/components/Layout.tsx | 2 - frontend/src/components/MetricsTable.test.tsx | 18 +++++++ frontend/src/components/MetricsTable.tsx | 14 ++--- frontend/src/pages/ExplorerPage.tsx | 5 -- 9 files changed, 60 insertions(+), 101 deletions(-) create mode 100644 current_tasks/frontend-review-round-3.md delete mode 100644 frontend/src/components/HealthBadge.test.tsx delete mode 100644 frontend/src/components/HealthBadge.tsx diff --git a/current_tasks/frontend-review-round-3.md b/current_tasks/frontend-review-round-3.md new file mode 100644 index 00000000..ff58fc9e --- /dev/null +++ b/current_tasks/frontend-review-round-3.md @@ -0,0 +1,33 @@ +# Frontend: third review round (5 October 2026) + +Branch: `frontend-good-enough`. Comments from the user after using the UI. + +## Decisions + +- **"N series" in the chart header**: removed, the series list says it (`Clear (3)`, `3 selected`). +- **End time is now**: a preset range is re-resolved every minute while the tab is visible, when the tab + comes back to the front, and when the active preset is clicked again. Queries keep their previous data + while the new range loads (no flicker), the zoom of the chart is kept. +- **Metric names**: a maximum width on the name column, truncated, the full name in the tooltip. +- **Selecting a metric selects its series** when there are at most 8 (the size of the palette: more would + repeat colours and the chart would be noise). More than that: the user picks. +- **Series list**: one column per label dimension, one row per series, the uuid small and last. The Type + column only appears when the series of the metric are not all of one type. This can happen: the server + groups metrics by (name, type) and `/series?metric=` filters on the name only, so a name that exists + as a float and as a string is two rows in the metrics list and one list of series. +- **Colours belong to the series list**: a swatch on each selected row, the echarts legend is gone. The + colour of a series is fixed while it is selected (lowest free slot), disabling another one does not + repaint it. Toggling a series animates in the chart (merge instead of a full replace). +- **"Connected" badge**: removed. A server that is down already shows the error of the request. +- **Logo, favicon, theme**: the SensApp logo in the header (light, dark and small variants of `public/`), a + favicon made from it, a theme taken from the logo blue, and the validated categorical palette of the + dataviz skill for the series (light and dark steps). + +## Steps + +1. [ ] Small cleanups: header count, health badge, metric name width, metric row keys +2. [ ] Live range +3. [ ] Series list: dimension columns, small uuid, type when mixed, colour swatch, auto-select +4. [ ] Chart: no legend, animated toggles, stable colours +5. [ ] Logo, favicon, theme, palette +6. [ ] Docs (`docs/FRONTEND.md`), live check in a browser, move to `done/` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0e1bb62c..71b2b7d6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -10,13 +10,11 @@ import { useSelectionStore } from './stores/useSelectionStore'; // Mock the API client with controllable responses const mockListMetrics = vi.fn(); const mockListSeries = vi.fn(); -const mockReadiness = vi.fn(); const mockGetSeriesData = vi.fn(); vi.mock('./client', () => ({ listMetrics: (...args: unknown[]) => mockListMetrics(...args), listSeries: (...args: unknown[]) => mockListSeries(...args), - readiness: (...args: unknown[]) => mockReadiness(...args), getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), })); @@ -84,7 +82,6 @@ describe('App integration', () => { vi.clearAllMocks(); mockListMetrics.mockResolvedValue({ data: catalogWithMetrics }); mockListSeries.mockResolvedValue({ data: seriesCatalog }); - mockReadiness.mockResolvedValue({ data: { status: 'ready', database: 'sqlite' } }); mockGetSeriesData.mockResolvedValue({ data: [] }); useSelectionStore.setState({ selectedMetric: null, @@ -93,11 +90,10 @@ describe('App integration', () => { }); }); - it('renders the header with logo, health badge, and docs link', async () => { + it('renders the header with the name and the docs link', async () => { render(, { wrapper: createWrapper() }); expect(screen.getByText('SensApp')).toBeInTheDocument(); - expect(await screen.findByText('Connected')).toBeInTheDocument(); const docsLink = screen.getByText('API Docs').closest('a'); expect(docsLink).toHaveAttribute('href', '/docs'); diff --git a/frontend/src/auth.test.tsx b/frontend/src/auth.test.tsx index 502974a9..3774b443 100644 --- a/frontend/src/auth.test.tsx +++ b/frontend/src/auth.test.tsx @@ -17,7 +17,6 @@ vi.mock('./client', () => ({ listMetrics: (...args: unknown[]) => mockListMetrics(...args), listSeries: vi.fn().mockResolvedValue({ data: { 'dcat:dataset': [] } }), getSeriesData: vi.fn().mockResolvedValue({ data: [] }), - readiness: vi.fn().mockResolvedValue({ data: { status: 'ready', database: 'sqlite' } }), })); const GOOD_TOKEN = fakeToken({ sub: 'alice', scope: 'read', exp: 4_102_444_800 }); diff --git a/frontend/src/components/HealthBadge.test.tsx b/frontend/src/components/HealthBadge.test.tsx deleted file mode 100644 index e2e7a9b3..00000000 --- a/frontend/src/components/HealthBadge.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import { HealthBadge } from '../components/HealthBadge'; - -const mockReadiness = vi.fn(); -vi.mock('../client', () => ({ - readiness: (...args: unknown[]) => mockReadiness(...args), -})); - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function Wrapper({ children }: { children: ReactNode }) { - return ( - {children} - ); - }; -} - -describe('HealthBadge', () => { - it('shows "Connected" when backend is healthy', async () => { - mockReadiness.mockResolvedValue({ - data: { status: 'ready', database: 'sqlite' }, - }); - - render(, { wrapper: createWrapper() }); - expect(await screen.findByText('Connected')).toBeInTheDocument(); - }); - - it('shows "Disconnected" when backend returns error', async () => { - mockReadiness.mockResolvedValue({ - data: undefined, - error: { status: 'error' }, - }); - - render(, { wrapper: createWrapper() }); - await waitFor(() => { - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - }); - - it('shows "Disconnected" on network failure', async () => { - mockReadiness.mockRejectedValue(new Error('Network error')); - - render(, { wrapper: createWrapper() }); - await waitFor(() => { - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/components/HealthBadge.tsx b/frontend/src/components/HealthBadge.tsx deleted file mode 100644 index 37a30b8b..00000000 --- a/frontend/src/components/HealthBadge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { readiness } from '../client'; -import type { ReadinessResponse } from '../client'; - -export function HealthBadge() { - const { data, isError } = useQuery({ - queryKey: ['health'], - queryFn: async () => { - const result = await readiness(); - if (result.error) { - throw new Error('Backend unreachable'); - } - return result.data as ReadinessResponse; - }, - refetchInterval: 30000, - retry: false, - }); - - const isHealthy = data?.status === 'ready' && !isError; - - return ( -
- - - {isHealthy ? 'Connected' : 'Disconnected'} - -
- ); -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 921a4d95..a13f3fac 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,6 @@ import { Link, Outlet } from 'react-router-dom'; import { AuthDialog } from './AuthDialog'; import { AuthStatus } from './AuthStatus'; -import { HealthBadge } from './HealthBadge'; export function Layout() { return ( @@ -21,7 +20,6 @@ export function Layout() {
- { expect(screen.getByText('region')).toBeInTheDocument(); }); + it('shows a name that exists with two types as two rows, the full name on hover', async () => { + const [cpu] = sampleMetrics['dcat:dataset']; + mockListMetrics.mockResolvedValue({ + data: { + ...sampleMetrics, + 'dcat:dataset': [cpu, { ...cpu, 'sensor:type': 'string' }], + }, + }); + render(, { wrapper: createWrapper() }); + + const names = await screen.findAllByText('cpu_usage'); + expect(names).toHaveLength(2); + expect(names[0].closest('[title]')).toHaveAttribute('title', 'cpu_usage'); + const table = screen.getByRole('table'); + expect(within(table).getByText('float')).toBeInTheDocument(); + expect(within(table).getByText('string')).toBeInTheDocument(); + }); + it('selects a metric when clicking a row and updates store', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); diff --git a/frontend/src/components/MetricsTable.tsx b/frontend/src/components/MetricsTable.tsx index f6526521..dbc89ae6 100644 --- a/frontend/src/components/MetricsTable.tsx +++ b/frontend/src/components/MetricsTable.tsx @@ -121,7 +121,7 @@ export function MetricsTable() { const dimensions = metric['sensor:labelDimensions'] ?? []; return ( handleSelectMetric(metric)} > - {name} - {metric['sensor:unit'] && ( - ({metric['sensor:unit']}) - )} +
+ {name} + {metric['sensor:unit'] && ( + ({metric['sensor:unit']}) + )} +
@@ -144,7 +146,7 @@ export function MetricsTable() { {seriesCount ?? '—'} -
+
{dimensions.slice(0, 5).map((dim) => ( {dim} diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 9fb653c6..311d4655 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -17,11 +17,6 @@ export function ExplorerPage() {

Chart

- {selectedSeries.length > 0 && ( - - {selectedSeries.length} series - - )}
From 04fcf3d932e0ec5a1c5b715c5d726f360d4ea335 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:01:32 +0200 Subject: [PATCH 17/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20a=20prese?= =?UTF-8?q?t=20range=20ends=20now,=20not=20when=20the=20page=20opened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset such as 1h is re-resolved every minute while the tab is on screen and as soon as it comes back. The chart keeps what it shows while the data of the moved range loads. Typed dates do not move. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/TimeSeriesChart.test.tsx | 15 ++++++ frontend/src/components/TimeSeriesChart.tsx | 9 ++-- frontend/src/hooks/useLiveRange.test.ts | 46 +++++++++++++++++++ frontend/src/hooks/useLiveRange.ts | 25 ++++++++++ frontend/src/pages/ExplorerPage.tsx | 2 + 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/useLiveRange.test.ts create mode 100644 frontend/src/hooks/useLiveRange.ts diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index 881a7b89..d09135e7 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -180,4 +180,19 @@ describe('TimeSeriesChart', () => { expect(option.series[0]).toMatchObject({ stack: 'total' }); expect(option.yAxis[0]).toMatchObject({ type: 'log' }); }); + + it('keeps the series drawn while the data of a moved range is on its way', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + + mockGetSeriesData.mockReturnValue(new Promise(() => {})); + act(() => useSelectionStore.getState().setTimeRange(hours(0.1), hours(1.1))); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + + expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1'); + }); }); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 56c8982b..ff825a45 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useMemo } from 'react'; -import { useQueries } from '@tanstack/react-query'; +import { keepPreviousData, useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; @@ -80,6 +80,8 @@ export function TimeSeriesChart() { }; }, enabled: !!s.uuid, + // The range moves every minute: the chart keeps what it shows until the new data is there + placeholderData: keepPreviousData, })), }); @@ -88,7 +90,8 @@ export function TimeSeriesChart() { const option = useMemo(() => { const series = queries - .filter((q) => q.data) + // What is kept while loading belongs to the series that had this place: not to another + .filter((q, i) => q.data && q.data.series.uuid === selectedSeries[i]?.uuid) .map((q) => { const { records, series, colorIndex } = q.data!; return { @@ -99,7 +102,7 @@ export function TimeSeriesChart() { }; }); return buildChartOption(series, chartStyle, logScale, timeRange); - }, [queries, timeRange, chartStyle, logScale]); + }, [queries, selectedSeries, timeRange, chartStyle, logScale]); if (selectedSeries.length === 0) { return null; diff --git a/frontend/src/hooks/useLiveRange.test.ts b/frontend/src/hooks/useLiveRange.test.ts new file mode 100644 index 00000000..aebb8521 --- /dev/null +++ b/frontend/src/hooks/useLiveRange.test.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { REFRESH_MS, useLiveRange } from './useLiveRange'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +describe('useLiveRange', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-05T10:00:00Z')); + useSelectionStore.getState().setRelativeRange('1h'); + }); + afterEach(() => vi.useRealTimers()); + + it('moves the end of a preset to now every minute', () => { + renderHook(() => useLiveRange()); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T10:00:00.000Z'); + + vi.advanceTimersByTime(REFRESH_MS); + const { timeRange, relativeRange } = useSelectionStore.getState(); + expect(timeRange).toEqual({ start: '2026-10-05T09:01:00.000Z', end: '2026-10-05T10:01:00.000Z' }); + expect(relativeRange).toBe('1h'); + }); + + it('moves it as soon as the tab is shown again', () => { + renderHook(() => useLiveRange()); + vi.setSystemTime(new Date('2026-10-05T12:00:00Z')); + document.dispatchEvent(new Event('visibilitychange')); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T12:00:00.000Z'); + }); + + it('leaves typed dates alone', () => { + const typed = { start: '2026-10-01T00:00:00.000Z', end: '2026-10-02T00:00:00.000Z' }; + useSelectionStore.getState().setTimeRange(typed.start, typed.end); + renderHook(() => useLiveRange()); + + vi.advanceTimersByTime(3 * REFRESH_MS); + expect(useSelectionStore.getState().timeRange).toEqual(typed); + }); + + it('stops when it is unmounted', () => { + const { unmount } = renderHook(() => useLiveRange()); + unmount(); + vi.advanceTimersByTime(REFRESH_MS); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T10:00:00.000Z'); + }); +}); diff --git a/frontend/src/hooks/useLiveRange.ts b/frontend/src/hooks/useLiveRange.ts new file mode 100644 index 00000000..3122e559 --- /dev/null +++ b/frontend/src/hooks/useLiveRange.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +export const REFRESH_MS = 60_000; + +/** + * A preset such as `1h` is the last hour, so its end is now: it moves on every minute while the + * tab is on screen, and at once when the tab comes back. Dates that were typed do not move. + */ +export function useLiveRange() { + const relativeRange = useSelectionStore((state) => state.relativeRange); + + useEffect(() => { + if (!relativeRange) return; + const refresh = () => { + if (!document.hidden) useSelectionStore.getState().setRelativeRange(relativeRange); + }; + const timer = setInterval(refresh, REFRESH_MS); + document.addEventListener('visibilitychange', refresh); + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }; + }, [relativeRange]); +} diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 311d4655..d7ca92ca 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -3,6 +3,7 @@ import { MetricsTable } from '../components/MetricsTable'; import { SeriesTable } from '../components/SeriesTable'; import { TimeSeriesChart } from '../components/TimeSeriesChart'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; +import { useLiveRange } from '../hooks/useLiveRange'; import { useUrlState } from '../hooks/useUrlState'; import { useSelectionStore } from '../stores/useSelectionStore'; @@ -10,6 +11,7 @@ export function ExplorerPage() { const { selectedMetric, selectedSeries, clearSelectedSeries } = useSelectionStore(); useUrlState(); + useLiveRange(); return (
From df30d8065b1b63d081ca1acb079173f0f359f86b Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:02:17 +0200 Subject: [PATCH 18/53] =?UTF-8?q?fix:=20=F0=9F=90=9B=20frontend:=20the=20c?= =?UTF-8?q?hart=20keeps=20its=20data=20while=20a=20moved=20range=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keepPreviousData does nothing with useQueries (a new key is a new observer, the previous commit's test caught it): the chart keeps the last data of each series itself. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/components/TimeSeriesChart.tsx | 37 ++++++++++++++------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index ff825a45..5ccd7978 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -1,7 +1,8 @@ -import { lazy, Suspense, useMemo } from 'react'; -import { keepPreviousData, useQueries } from '@tanstack/react-query'; +import { lazy, Suspense, useMemo, useState } from 'react'; +import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; +import type { SelectedSeries } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; import { isBooleanType, isNumericType, resolveStep } from '../lib/chartStep'; import { buildChartOption } from '../lib/chartOption'; @@ -53,6 +54,13 @@ function parseSenMLToTimeSeries( return points.sort((a, b) => a[0] - b[0]); } +/** What a query returns: the records of a series, and the color slot it was drawn with. */ +interface Loaded { + records: SenMLRecord[]; + series: SelectedSeries; + colorIndex: number; +} + export function TimeSeriesChart() { const { selectedSeries, timeRange, step: stepChoice, aggregation, chartStyle, logScale } = useSelectionStore(); @@ -80,29 +88,34 @@ export function TimeSeriesChart() { }; }, enabled: !!s.uuid, - // The range moves every minute: the chart keeps what it shows until the new data is there - placeholderData: keepPreviousData, })), }); const isLoading = queries.some((q) => q.isLoading); const errors = queries.filter((q) => q.error); + // The range moves every minute and a query has a key per range: the chart keeps what it shows + // until the data of the new range is there. (`placeholderData` cannot do it for `useQueries`.) + const [shown, setShown] = useState>({}); + const loaded = queries.flatMap((q) => (q.data ? [q.data] : [])); + if (loaded.some((data) => shown[data.series.uuid] !== data)) { + setShown({ ...shown, ...Object.fromEntries(loaded.map((data) => [data.series.uuid, data])) }); + } + const option = useMemo(() => { - const series = queries - // What is kept while loading belongs to the series that had this place: not to another - .filter((q, i) => q.data && q.data.series.uuid === selectedSeries[i]?.uuid) - .map((q) => { - const { records, series, colorIndex } = q.data!; + const series = selectedSeries + .filter((s) => shown[s.uuid]) + .map((s) => { + const { records, colorIndex } = shown[s.uuid]; return { - name: buildSeriesLabel(series.name, series.labels), + name: buildSeriesLabel(s.name, s.labels), points: parseSenMLToTimeSeries(records), - boolean: isBooleanType(series.type), + boolean: isBooleanType(s.type), color: COLORS[colorIndex % COLORS.length], }; }); return buildChartOption(series, chartStyle, logScale, timeRange); - }, [queries, selectedSeries, timeRange, chartStyle, logScale]); + }, [shown, selectedSeries, timeRange, chartStyle, logScale]); if (selectedSeries.length === 0) { return null; From 6c367cbab741a68efabcbc0647a530724971822a Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:04:34 +0200 Subject: [PATCH 19/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20series=20?= =?UTF-8?q?by=20label=20dimension,=20a=20color=20each,=20no=20chart=20lege?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series list has a column per label dimension and the uuid in small, last. The type is a column only when the series of a name are not all of one type (the server groups metrics by name and type). A selected series shows its color, which it keeps for as long as it is selected, and the chart has no legend of its own. A metric of at most 8 series (the palette) is selected whole, once, when it is chosen; strings are left out as they are not drawn. The chart merges a new option instead of replacing everything, so a series comes and goes with an animation and the zoom stays. The colors are the dataviz palette, validated in both themes. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/components/EChart.tsx | 26 +++- frontend/src/components/SeriesTable.test.tsx | 117 ++++++++++++-- frontend/src/components/SeriesTable.tsx | 143 +++++++++++------- .../src/components/TimeSeriesChart.test.tsx | 21 ++- frontend/src/components/TimeSeriesChart.tsx | 40 ++--- frontend/src/hooks/useUrlState.test.tsx | 6 +- frontend/src/hooks/useUrlState.ts | 7 +- frontend/src/lib/chartOption.test.ts | 20 ++- frontend/src/lib/chartOption.ts | 12 +- frontend/src/lib/palette.test.ts | 22 +++ frontend/src/lib/palette.ts | 29 ++++ frontend/src/stores/useSelectionStore.test.ts | 24 +++ frontend/src/stores/useSelectionStore.ts | 30 +++- 13 files changed, 386 insertions(+), 111 deletions(-) create mode 100644 frontend/src/lib/palette.test.ts create mode 100644 frontend/src/lib/palette.ts diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx index c19bb7ef..4f8c556e 100644 --- a/frontend/src/components/EChart.tsx +++ b/frontend/src/components/EChart.tsx @@ -4,7 +4,6 @@ import { BarChart, LineChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, - LegendComponent, ToolboxComponent, TooltipComponent, } from 'echarts/components'; @@ -17,14 +16,23 @@ echarts.use([ LineChart, DataZoomComponent, GridComponent, - LegendComponent, ToolboxComponent, TooltipComponent, CanvasRenderer, ]); -/** Minimal echarts wrapper: one instance per mount, resized with its container. */ -export default function EChart({ option }: { option: echarts.EChartsCoreOption }) { +/** + * Minimal echarts wrapper: one instance per mount, resized with its container. A new option is merged + * into what is drawn, so that a series that is added or removed comes and goes with an animation + * while the others stay (and the zoom with them). `resetZoomKey` is what the zoom belongs to. + */ +export default function EChart({ + option, + resetZoomKey, +}: { + option: echarts.EChartsCoreOption; + resetZoomKey?: string; +}) { const container = useRef(null); const chart = useRef(null); const dark = usePrefersDark(); @@ -46,8 +54,16 @@ export default function EChart({ option }: { option: echarts.EChartsCoreOption } useEffect(() => { // The theme brings a background of its own: the card has one already - chart.current?.setOption({ backgroundColor: 'transparent', ...option }, true); + chart.current?.setOption( + { backgroundColor: 'transparent', ...option }, + // What a merge would keep of a series or an axis that is gone + { replaceMerge: ['series', 'yAxis'] }, + ); }, [option, dark]); + useEffect(() => { + chart.current?.dispatchAction({ type: 'dataZoom', start: 0, end: 100 }); + }, [resetZoomKey]); + return
; } diff --git a/frontend/src/components/SeriesTable.test.tsx b/frontend/src/components/SeriesTable.test.tsx index 9dfecef7..afa17d94 100644 --- a/frontend/src/components/SeriesTable.test.tsx +++ b/frontend/src/components/SeriesTable.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -42,16 +42,115 @@ describe('SeriesTable', () => { useSelectionStore.setState({ selectedMetric: 'temperature', selectedSeries: [], labelFilter: '' }); }); - it('lists the series of the metric and selects them', async () => { - const user = userEvent.setup(); + it('lists the series of the metric with a column for each label, and the uuid in small', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'dcat:dataset': [ + { ...dataset('uuid-a', 'alpha'), 'sensor:labels': [{ host: 'alpha', room: 'lab' }] }, + dataset('uuid-b', 'beta'), + ], + }, + }); + renderTable(); + + await screen.findByText('uuid-a'); + const headers = screen.getAllByRole('columnheader').map((h) => h.textContent); + expect(headers).toEqual(['Select', 'host', 'room', 'Series ID']); + const [, alpha, beta] = screen.getAllByRole('row'); + expect(within(alpha).getAllByRole('cell').map((c) => c.textContent)).toEqual(['', 'alpha', 'lab', 'uuid-a']); + // A label that a series does not have is a dash + expect(within(beta).getAllByRole('cell').map((c) => c.textContent)).toEqual(['', 'beta', '—', 'uuid-b']); + // The type is the one of the metric: no column for it + expect(screen.queryByText('float')).not.toBeInTheDocument(); + }); + + it('says the type of each series when the metric has more than one', async () => { + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'dcat:dataset': [dataset('uuid-a', 'alpha'), { ...dataset('uuid-b', 'beta'), 'sensor:type': 'string' }] }, + }); renderTable(); - await user.click(await screen.findByRole('checkbox', { name: 'Select series uuid-a' })); - expect(useSelectionStore.getState().selectedSeries).toEqual([ - { uuid: 'uuid-a', name: 'temperature', labels: { host: 'alpha' }, type: 'float' }, - ]); - expect(mockListSeries).toHaveBeenCalledWith({ - query: { metric: 'temperature', selector: undefined, bookmark: undefined }, + await screen.findByText('uuid-a'); + expect(screen.getByRole('columnheader', { name: 'Type' })).toBeInTheDocument(); + expect(screen.getByText('float')).toBeInTheDocument(); + expect(screen.getByText('string')).toBeInTheDocument(); + }); + + describe('selection', () => { + const uuids = () => useSelectionStore.getState().selectedSeries.map((s) => s.uuid); + + it('selects all the series of a metric that is not too big, with a color each', async () => { + renderTable(); + + await waitFor(() => expect(uuids()).toEqual(['uuid-a', 'uuid-b'])); + expect(useSelectionStore.getState().selectedSeries[0]).toEqual({ + uuid: 'uuid-a', + name: 'temperature', + labels: { host: 'alpha' }, + type: 'float', + slot: 0, + }); + expect(await screen.findAllByTestId('series-color')).toHaveLength(2); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + it('does not decide for the user when there are too many series', async () => { + const many = Array.from({ length: 9 }, (_, i) => dataset(`uuid-${i}`, `host-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + + await screen.findByText('uuid-0'); + expect(uuids()).toEqual([]); + }); + + it('does not decide either when there is another page', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'hydra:view': { '@type': 'hydra:PartialCollectionView', 'hydra:next': '/series?bookmark=p2', 'hydra:itemsPerPage': 2 }, + }, + }); + renderTable(); + + await screen.findByText('uuid-a'); + expect(uuids()).toEqual([]); + }); + + it('leaves what the address selected', async () => { + useSelectionStore.getState().selectSeries([{ uuid: 'uuid-b', name: 'temperature', labels: { host: 'beta' }, type: 'float' }]); + renderTable(); + + await screen.findByText('uuid-a'); + expect(uuids()).toEqual(['uuid-b']); + }); + + it('does not select what cannot be drawn', async () => { + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'dcat:dataset': [dataset('uuid-a', 'alpha'), { ...dataset('uuid-b', 'beta'), 'sensor:type': 'string' }] }, + }); + renderTable(); + + await waitFor(() => expect(uuids()).toEqual(['uuid-a'])); + }); + + it('does it once: what the user clears stays cleared', async () => { + const user = userEvent.setup(); + renderTable(); + await waitFor(() => expect(uuids()).toHaveLength(2)); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-a' })); + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-b' })); + expect(uuids()).toEqual([]); + }); + + it('keeps the color of a series when another one is unselected', async () => { + const user = userEvent.setup(); + renderTable(); + await waitFor(() => expect(uuids()).toHaveLength(2)); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-a' })); + expect(useSelectionStore.getState().selectedSeries).toMatchObject([{ uuid: 'uuid-b', slot: 1 }]); }); }); diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index fbc3c851..0058b35e 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -1,7 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { nextBookmark, useSeries } from '../hooks/useSeries'; import type { SeriesDataset } from '../hooks/useSeries'; +import { isBooleanType, isNumericType } from '../lib/chartStep'; +import { seriesColor, SLOTS } from '../lib/palette'; +import { usePrefersDark } from '../lib/usePrefersDark'; import { useSelectionStore } from '../stores/useSelectionStore'; +import type { SeriesInfo } from '../stores/useSelectionStore'; import { ErrorAlert, Loading } from './Feedback'; function labelsToRecord( @@ -14,14 +18,28 @@ function labelsToRecord( ); } +/** A metric of at most this many series is shown whole when it is selected: there is a color for each. */ +const AUTO_SELECT_MAX = SLOTS; + +function toSeriesInfo(dataset: SeriesDataset): SeriesInfo { + return { + uuid: dataset['dct:identifier'], + name: dataset['dct:title'], + labels: labelsToRecord(dataset['sensor:labels']), + type: dataset['sensor:type'], + }; +} + export function SeriesTable() { const { selectedMetric, selectedSeries, toggleSeries, + selectSeries, labelFilter, setLabelFilter, } = useSelectionStore(); + const dark = usePrefersDark(); const [selectorInput, setSelectorInput] = useState(''); // The cursor of every page shown so far: the server only goes forward, so Previous pops one const [bookmarks, setBookmarks] = useState([]); @@ -37,21 +55,31 @@ export function SeriesTable() { const series = data?.['dcat:dataset'] ?? []; - function isSelected(dataset: SeriesDataset): boolean { - return selectedSeries.some( - (s) => s.uuid === dataset['dct:identifier'] + // A metric that is not too big is shown whole, once: clearing it afterwards is the choice of the + // user. What the address already shows is left alone, and so are the series that cannot be drawn. + const autoSelected = useRef(false); + useEffect(() => { + if (!data || autoSelected.current) return; + autoSelected.current = true; + const all = data['dcat:dataset']; + if (nextBookmark(data) || all.length > AUTO_SELECT_MAX) return; + if (useSelectionStore.getState().selectedSeries.length > 0) return; + selectSeries( + all + .map(toSeriesInfo) + .filter((s) => isNumericType(s.type) || isBooleanType(s.type)), ); - } + }, [data, selectSeries]); - function handleToggle(dataset: SeriesDataset) { - toggleSeries({ - uuid: dataset['dct:identifier'], - name: dataset['dct:title'], - labels: labelsToRecord(dataset['sensor:labels']), - type: dataset['sensor:type'], - }); + function selectedOf(dataset: SeriesDataset) { + return selectedSeries.find((s) => s.uuid === dataset['dct:identifier']); } + // One column per label dimension + const dimensions = [...new Set(series.flatMap((s) => Object.keys(labelsToRecord(s['sensor:labels']))))].sort(); + // The type is said when there is something to tell: a name can exist with more than one + const mixedTypes = new Set(series.map((s) => s['sensor:type'])).size > 1; + // Client-side label text filtering const filteredSeries = labelFilter ? series.filter((s) => { @@ -113,63 +141,72 @@ export function SeriesTable() { - - - - + {dimensions.map((dimension) => ( + + ))} + {dimensions.length === 0 && } + {mixedTypes && } + {filteredSeries.map((s) => { - const checked = isSelected(s); + const uuid = s['dct:identifier']; + const selected = selectedOf(s); + const labels = labelsToRecord(s['sensor:labels']); return ( handleToggle(s)} + onClick={() => toggleSeries(toSeriesInfo(s))} > - - - + {dimensions.map((dimension) => ( + + ))} + {dimensions.length === 0 && ( + + )} + {mixedTypes && ( + + )} + ); })} diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index d09135e7..0673d241 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { TimeSeriesChart } from './TimeSeriesChart'; +import { SERIES_COLORS } from '../lib/palette'; import { useSelectionStore } from '../stores/useSelectionStore'; const mockGetSeriesData = vi.fn(); @@ -13,9 +14,9 @@ vi.mock('../client', () => ({ const START = '2026-10-04T00:00:00.000Z'; const hours = (n: number) => new Date(Date.parse(START) + n * 3600_000).toISOString(); -const temperature = { uuid: 'uuid-temperature', name: 'temperature', labels: {}, type: 'float' }; -const state = { uuid: 'uuid-state', name: 'state', labels: {}, type: 'string' }; -const door = { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean' }; +const temperature = { uuid: 'uuid-temperature', name: 'temperature', labels: {}, type: 'float', slot: 0 }; +const state = { uuid: 'uuid-state', name: 'state', labels: {}, type: 'string', slot: 0 }; +const door = { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean', slot: 0 }; function renderChart() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -195,4 +196,18 @@ describe('TimeSeriesChart', () => { expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1'); }); + + it('draws each series with the color of its slot, the one of its swatch in the list', async () => { + useSelectionStore.setState({ + selectedSeries: [{ ...temperature, slot: 3 }], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ id: 'uuid-temperature', color: SERIES_COLORS.light[3] }); + expect(option.legend).toBeUndefined(); + }); }); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 5ccd7978..1bf91ba1 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -2,20 +2,15 @@ import { lazy, Suspense, useMemo, useState } from 'react'; import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; -import type { SelectedSeries } from '../stores/useSelectionStore'; import { unwrap } from '../api/clientConfig'; import { isBooleanType, isNumericType, resolveStep } from '../lib/chartStep'; import { buildChartOption } from '../lib/chartOption'; +import { seriesColor } from '../lib/palette'; +import { usePrefersDark } from '../lib/usePrefersDark'; // echarts is large: load it when the first chart is drawn. const EChart = lazy(() => import('./EChart')); -// Color palette for multiple series -const COLORS = [ - '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', - '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1', -]; - function buildSeriesLabel(name: string, labels: Record): string { const labelStr = Object.entries(labels) .map(([k, v]) => `${k}="${v}"`) @@ -54,21 +49,21 @@ function parseSenMLToTimeSeries( return points.sort((a, b) => a[0] - b[0]); } -/** What a query returns: the records of a series, and the color slot it was drawn with. */ +/** What a query returns: the records of a series. */ interface Loaded { + uuid: string; records: SenMLRecord[]; - series: SelectedSeries; - colorIndex: number; } export function TimeSeriesChart() { - const { selectedSeries, timeRange, step: stepChoice, aggregation, chartStyle, logScale } = + const { selectedSeries, timeRange, relativeRange, step: stepChoice, aggregation, chartStyle, logScale } = useSelectionStore(); + const dark = usePrefersDark(); const step = resolveStep(stepChoice, timeRange.start, timeRange.end); const queries = useQueries({ - queries: selectedSeries.map((s, index) => ({ + queries: selectedSeries.map((s) => ({ queryKey: ['seriesData', s.uuid, timeRange, step, aggregation] as const, queryFn: async () => { const result = await getSeriesData({ @@ -81,11 +76,7 @@ export function TimeSeriesChart() { ...(step && isNumericType(s.type) ? { step, aggregation } : {}), }, }); - return { - records: unwrap(result) as unknown as SenMLRecord[], - series: s, - colorIndex: index, - }; + return { uuid: s.uuid, records: unwrap(result) as unknown as SenMLRecord[] }; }, enabled: !!s.uuid, })), @@ -98,24 +89,24 @@ export function TimeSeriesChart() { // until the data of the new range is there. (`placeholderData` cannot do it for `useQueries`.) const [shown, setShown] = useState>({}); const loaded = queries.flatMap((q) => (q.data ? [q.data] : [])); - if (loaded.some((data) => shown[data.series.uuid] !== data)) { - setShown({ ...shown, ...Object.fromEntries(loaded.map((data) => [data.series.uuid, data])) }); + if (loaded.some((data) => shown[data.uuid] !== data)) { + setShown({ ...shown, ...Object.fromEntries(loaded.map((data) => [data.uuid, data])) }); } const option = useMemo(() => { const series = selectedSeries .filter((s) => shown[s.uuid]) .map((s) => { - const { records, colorIndex } = shown[s.uuid]; return { + id: s.uuid, name: buildSeriesLabel(s.name, s.labels), - points: parseSenMLToTimeSeries(records), + points: parseSenMLToTimeSeries(shown[s.uuid].records), boolean: isBooleanType(s.type), - color: COLORS[colorIndex % COLORS.length], + color: seriesColor(s.slot, dark), }; }); return buildChartOption(series, chartStyle, logScale, timeRange); - }, [shown, selectedSeries, timeRange, chartStyle, logScale]); + }, [shown, selectedSeries, dark, timeRange, chartStyle, logScale]); if (selectedSeries.length === 0) { return null; @@ -138,7 +129,8 @@ export function TimeSeriesChart() {
- + {/* A preset slides with the time, zoom is kept; another range starts from the whole of it */} +
diff --git a/frontend/src/hooks/useUrlState.test.tsx b/frontend/src/hooks/useUrlState.test.tsx index c2a30347..ae403004 100644 --- a/frontend/src/hooks/useUrlState.test.tsx +++ b/frontend/src/hooks/useUrlState.test.tsx @@ -77,10 +77,10 @@ describe('useUrlState', () => { chartStyle: 'area', logScale: true, }); - // The name, the labels and the type come from the first sample of each series + // The name, the labels and the type come from the first sample of each series, a color slot each expect(state.selectedSeries).toEqual([ - { uuid: 'uuid-t', name: 'temperature', labels: { room: 'lab' }, type: 'float' }, - { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean' }, + { uuid: 'uuid-t', name: 'temperature', labels: { room: 'lab' }, type: 'float', slot: 0 }, + { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean', slot: 1 }, ]); const hours = (Date.parse(state.timeRange.end) - Date.parse(state.timeRange.start)) / 3_600_000; expect(hours).toBe(24); diff --git a/frontend/src/hooks/useUrlState.ts b/frontend/src/hooks/useUrlState.ts index 7536ce7f..4c7f0156 100644 --- a/frontend/src/hooks/useUrlState.ts +++ b/frontend/src/hooks/useUrlState.ts @@ -6,7 +6,8 @@ import { ApiError, unwrap } from '../api/clientConfig'; import { fromSearchParams, toSearchParams } from '../lib/urlState'; import { rangeFor } from '../lib/timeRange'; import { useSelectionStore } from '../stores/useSelectionStore'; -import type { SelectedSeries } from '../stores/useSelectionStore'; +import { withSlots } from '../stores/useSelectionStore'; +import type { SeriesInfo } from '../stores/useSelectionStore'; /** The type of a series, from the value its first record carries. */ function typeOf(record: Record): string { @@ -20,7 +21,7 @@ function typeOf(record: Record): string { * What the chart needs to know of a series that an address names by its uuid: its first sample * carries the name and the labels. `null` for a series that is gone. */ -async function probeSeries(uuid: string): Promise { +async function probeSeries(uuid: string): Promise { try { const result = await getSeriesData({ path: { series_uuid: uuid }, @@ -89,7 +90,7 @@ export function useUrlState() { useEffect(() => { if (!settled) return; const series = probesRef.current.flatMap((probe) => (probe.data ? [probe.data] : [])); - if (series.length > 0) useSelectionStore.setState({ selectedSeries: series }); + if (series.length > 0) useSelectionStore.setState({ selectedSeries: withSlots(series) }); const write = (state: ReturnType) => setParamsRef.current( diff --git a/frontend/src/lib/chartOption.test.ts b/frontend/src/lib/chartOption.test.ts index 4218c312..a0872452 100644 --- a/frontend/src/lib/chartOption.test.ts +++ b/frontend/src/lib/chartOption.test.ts @@ -3,8 +3,8 @@ import { buildChartOption } from './chartOption'; import type { ChartSeries } from './chartOption'; const RANGE = { start: '2026-10-04T00:00:00.000Z', end: '2026-10-04T01:00:00.000Z' }; -const temperature: ChartSeries = { name: 'temperature', points: [[1, 20], [2, 21]], boolean: false, color: '#111' }; -const door: ChartSeries = { name: 'door', points: [[1, 0], [2, 1]], boolean: true, color: '#222' }; +const temperature: ChartSeries = { id: 'uuid-t', name: 'temperature', points: [[1, 20], [2, 21]], boolean: false, color: '#111' }; +const door: ChartSeries = { id: 'uuid-d', name: 'door', points: [[1, 0], [2, 1]], boolean: true, color: '#222' }; type Option = { series: Array>; yAxis: Array>; grid: { right: number } }; const build = (series: ChartSeries[], style: Parameters[1] = 'line', log = false) => @@ -19,6 +19,22 @@ describe('buildChartOption', () => { }); }); + it('has no legend (the list of series has the colors), and tells its series by id', () => { + const option = build([temperature, door]) as unknown as { legend?: unknown }; + expect(option.legend).toBeUndefined(); + expect(build([temperature, door]).series.map((s) => s.id)).toEqual(['uuid-t', 'uuid-d']); + }); + + it('leaves the zoom to the user: it has no start and no end to undo it', () => { + const { dataZoom } = buildChartOption([temperature], 'line', false, RANGE) as unknown as { + dataZoom: Array>; + }; + for (const zoom of dataZoom) { + expect(zoom).not.toHaveProperty('start'); + expect(zoom).not.toHaveProperty('end'); + } + }); + it('knows its styles', () => { expect(build([temperature], 'step').series[0]).toMatchObject({ type: 'line', step: 'end' }); expect(build([temperature], 'area').series[0]).toMatchObject({ type: 'line', areaStyle: { opacity: 0.2 } }); diff --git a/frontend/src/lib/chartOption.ts b/frontend/src/lib/chartOption.ts index 5412e228..e3e3f130 100644 --- a/frontend/src/lib/chartOption.ts +++ b/frontend/src/lib/chartOption.ts @@ -4,6 +4,8 @@ export type ChartStyle = 'line' | 'step' | 'area' | 'stacked' | 'bars'; export const CHART_STYLES: ChartStyle[] = ['line', 'step', 'area', 'stacked', 'bars']; export interface ChartSeries { + /** What echarts tells the series by when the option changes: a series that stays is not redrawn */ + id: string; name: string; points: Array<[number, number]>; /** Drawn as a 0/1 step line on an axis of its own, whatever the style */ @@ -23,6 +25,7 @@ export function buildChartOption( const lines = series.map((s) => { if (s.boolean) { return { + id: s.id, name: s.name, type: 'line' as const, step: 'end' as const, @@ -34,6 +37,7 @@ export function buildChartOption( }; } return { + id: s.id, name: s.name, type: style === 'bars' ? ('bar' as const) : ('line' as const), data: s.points, @@ -51,8 +55,7 @@ export function buildChartOption( return { tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, - legend: { data: series.map((s) => s.name), type: 'scroll', bottom: 0 }, - grid: { top: 40, right: hasBoolean ? 60 : 40, bottom: 60, left: 60 }, + grid: { top: 40, right: hasBoolean ? 60 : 40, bottom: 50, left: 60 }, xAxis: { type: 'time', min: new Date(range.start).getTime(), @@ -75,8 +78,9 @@ export function buildChartOption( : []), ], dataZoom: [ - { type: 'inside', start: 0, end: 100 }, - { type: 'slider', start: 0, end: 100, bottom: 30, height: 20 }, + // No start and end: the zoom of the user is not undone when the data changes + { type: 'inside' }, + { type: 'slider', bottom: 8, height: 20 }, ], toolbox: { feature: { saveAsImage: {}, dataZoom: {}, restore: {} } }, series: lines, diff --git a/frontend/src/lib/palette.test.ts b/frontend/src/lib/palette.test.ts new file mode 100644 index 00000000..7e4952a0 --- /dev/null +++ b/frontend/src/lib/palette.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { freeSlot, SERIES_COLORS, seriesColor, SLOTS } from './palette'; + +describe('palette', () => { + it('has a step of each hue for each theme', () => { + expect(SERIES_COLORS.light).toHaveLength(SLOTS); + expect(SERIES_COLORS.dark).toHaveLength(SLOTS); + expect(seriesColor(0, false)).not.toBe(seriesColor(0, true)); + }); + + it('gives the first slot nobody uses', () => { + expect(freeSlot([])).toBe(0); + expect(freeSlot([0, 1])).toBe(2); + expect(freeSlot([0, 2])).toBe(1); + }); + + it('uses the colors again when they are all taken', () => { + const all = Array.from({ length: SLOTS }, (_, i) => i); + expect(freeSlot(all)).toBe(0); + expect(seriesColor(SLOTS + 1, false)).toBe(seriesColor(1, false)); + }); +}); diff --git a/frontend/src/lib/palette.ts b/frontend/src/lib/palette.ts new file mode 100644 index 00000000..a7f5d404 --- /dev/null +++ b/frontend/src/lib/palette.ts @@ -0,0 +1,29 @@ +/** + * The colors of the series, in the order they are given: eight hues, one step for each theme (the + * steps of the light theme are too light on the dark surface). Checked with the dataviz skill's + * `validate_palette.js`: worst neighbours 9.1 (light) and 8.4 (dark) of color-blind distance, 19+ for + * normal vision, 3:1 on the dark surface. On the light surface three are under 3:1: the series list + * says which color is which, so a color is never the only way to tell the series apart. + */ +export const SERIES_COLORS = { + light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'], + dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'], +}; + +export const SLOTS = SERIES_COLORS.light.length; + +export function seriesColor(slot: number, dark: boolean): string { + const colors = dark ? SERIES_COLORS.dark : SERIES_COLORS.light; + return colors[slot % colors.length]; +} + +/** + * The slot of a series that is selected: the first one nobody uses, so that a color follows its + * series for as long as it is selected. Past `SLOTS` series the colors are used again. + */ +export function freeSlot(used: number[]): number { + for (let slot = 0; slot < SLOTS; slot++) { + if (!used.includes(slot)) return slot; + } + return used.length % SLOTS; +} diff --git a/frontend/src/stores/useSelectionStore.test.ts b/frontend/src/stores/useSelectionStore.test.ts index 22b96406..f392cb89 100644 --- a/frontend/src/stores/useSelectionStore.test.ts +++ b/frontend/src/stores/useSelectionStore.test.ts @@ -105,4 +105,28 @@ describe('useSelectionStore', () => { }); expect(result.current.selectedSeries).toHaveLength(0); }); + + describe('colors', () => { + const info = (uuid: string) => ({ uuid, name: 'cpu', labels: {}, type: 'float' }); + const slots = () => useSelectionStore.getState().selectedSeries.map((s) => [s.uuid, s.slot]); + + it('keeps the color of a series as long as it is selected', () => { + const { toggleSeries } = useSelectionStore.getState(); + act(() => ['a', 'b', 'c'].forEach((uuid) => toggleSeries(info(uuid)))); + expect(slots()).toEqual([['a', 0], ['b', 1], ['c', 2]]); + + // The first one goes: the others keep theirs, and the next one takes the free one + act(() => toggleSeries(info('a'))); + expect(slots()).toEqual([['b', 1], ['c', 2]]); + act(() => toggleSeries(info('d'))); + expect(slots()).toEqual([['b', 1], ['c', 2], ['d', 0]]); + }); + + it('selects many series at once, those that are already selected staying as they are', () => { + const { toggleSeries, selectSeries } = useSelectionStore.getState(); + act(() => toggleSeries(info('b'))); + act(() => selectSeries([info('a'), info('b'), info('c')])); + expect(slots()).toEqual([['b', 0], ['a', 1], ['c', 2]]); + }); + }); }); diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index be222b2e..7d069088 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -1,15 +1,32 @@ import { create } from 'zustand'; import type { Aggregation } from '../lib/chartStep'; import type { ChartStyle } from '../lib/chartOption'; +import { freeSlot } from '../lib/palette'; import { DEFAULT_RANGE, rangeFor } from '../lib/timeRange'; -export interface SelectedSeries { +/** What the server says of a series. */ +export interface SeriesInfo { uuid: string; name: string; labels: Record; type: string; } +/** A series that is shown, with the color slot it keeps for as long as it is. */ +export interface SelectedSeries extends SeriesInfo { + slot: number; +} + +/** The series with a slot each, the ones that have one keeping it. */ +export function withSlots(series: SeriesInfo[], selected: SelectedSeries[] = []): SelectedSeries[] { + const result = [...selected]; + for (const info of series) { + if (result.some((s) => s.uuid === info.uuid)) continue; + result.push({ ...info, slot: freeSlot(result.map((s) => s.slot)) }); + } + return result; +} + interface SelectionState { selectedMetric: string | null; selectedSeries: SelectedSeries[]; @@ -26,7 +43,9 @@ interface SelectionState { aggregation: Aggregation; labelFilter: string; setSelectedMetric: (metric: string | null) => void; - toggleSeries: (series: SelectedSeries) => void; + toggleSeries: (series: SeriesInfo) => void; + /** Shows these series too */ + selectSeries: (series: SeriesInfo[]) => void; clearSelectedSeries: () => void; setTimeRange: (start: string, end: string) => void; /** A preset: the range ending now */ @@ -71,11 +90,12 @@ export const useSelectionStore = create((set) => ({ ), }; } - return { - selectedSeries: [...state.selectedSeries, series], - }; + return { selectedSeries: withSlots([series], state.selectedSeries) }; }), + selectSeries: (series) => + set((state) => ({ selectedSeries: withSlots(series, state.selectedSeries) })), + clearSelectedSeries: () => set({ selectedSeries: [] }), setTimeRange: (start, end) => From 01157c8ff4ffa2c9225b8c7f4283520adc5496a7 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:09:27 +0200 Subject: [PATCH 20/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20Sen?= =?UTF-8?q?sApp=20logo=20and=20favicon,=20and=20a=20theme=20taken=20from?= =?UTF-8?q?=20the=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header shows the logo (white on a dark page, without the name on a narrow screen), the favicon is the box and the horn of it. The primary color is the navy of the logo, the neutral one the grey of its dish, in a light and a dark theme of our own. The type badges of the metrics were all grey: the server sends Float and String, and the colors were chosen on float and string. The metric names are cut at the width that the other columns leave, so the table does not scroll sideways. Co-Authored-By: Claude Sonnet 5.5 --- frontend/index.html | 2 +- frontend/public/favicon.png | Bin 0 -> 3437 bytes frontend/public/favicon.svg | 1 - frontend/public/sensapp_logo-fs8.png | Bin 0 -> 9493 bytes frontend/public/sensapp_logo_small-fs8.png | Bin 0 -> 6605 bytes .../public/sensapp_logo_small_white-fs8.png | Bin 0 -> 5922 bytes frontend/public/sensapp_logo_white-fs8.png | Bin 0 -> 8290 bytes frontend/src/App.test.tsx | 4 +- frontend/src/components/Layout.tsx | 20 ++++-- frontend/src/components/MetricsTable.test.tsx | 11 ++++ frontend/src/components/MetricsTable.tsx | 12 ++-- frontend/src/components/SeriesTable.tsx | 2 +- frontend/src/index.css | 57 +++++++++++++++--- 13 files changed, 86 insertions(+), 23 deletions(-) create mode 100644 frontend/public/favicon.png delete mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/sensapp_logo-fs8.png create mode 100644 frontend/public/sensapp_logo_small-fs8.png create mode 100644 frontend/public/sensapp_logo_small_white-fs8.png create mode 100644 frontend/public/sensapp_logo_white-fs8.png diff --git a/frontend/index.html b/frontend/index.html index c73192c9..21c54905 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + SensApp diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..14d3d27ba03b136ef940078ba038c4b94dd5389d GIT binary patch literal 3437 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 BxoH3Z literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index e1738114..00000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/public/sensapp_logo-fs8.png b/frontend/public/sensapp_logo-fs8.png new file mode 100644 index 0000000000000000000000000000000000000000..594539a0ec1ede28f37b4764548aec0a22881f41 GIT binary patch literal 9493 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 { it('renders the header with the name and the docs link', async () => { render(, { wrapper: createWrapper() }); - expect(screen.getByText('SensApp')).toBeInTheDocument(); + expect(screen.getByAltText('SensApp')).toBeInTheDocument(); const docsLink = screen.getByText('API Docs').closest('a'); expect(docsLink).toHaveAttribute('href', '/docs'); @@ -135,7 +135,7 @@ describe('App integration', () => { it('renders the application shell', () => { render(, { wrapper: createWrapper() }); - expect(screen.getByText('SensApp')).toBeInTheDocument(); + expect(screen.getByAltText('SensApp')).toBeInTheDocument(); expect(screen.getByText('Sensor Data Explorer')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a13f3fac..5e0c07ca 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,6 +2,13 @@ import { Link, Outlet } from 'react-router-dom'; import { AuthDialog } from './AuthDialog'; import { AuthStatus } from './AuthStatus'; +// The files of public/, served next to the page (`BASE_URL` is `/ui/` when SensApp serves them) +const base = import.meta.env.BASE_URL; +const logo = `${base}sensapp_logo-fs8.png`; +const logoWhite = `${base}sensapp_logo_white-fs8.png`; +const logoSmall = `${base}sensapp_logo_small-fs8.png`; +const logoSmallWhite = `${base}sensapp_logo_small_white-fs8.png`; + export function Layout() { return (
@@ -9,11 +16,14 @@ export function Layout() {
- - - - - SensApp + + {/* The white logo on a dark page, the small one (no name) on a narrow screen */} + + + + + SensApp +
Sensor Data Explorer diff --git a/frontend/src/components/MetricsTable.test.tsx b/frontend/src/components/MetricsTable.test.tsx index 5b85e46b..a15c615a 100644 --- a/frontend/src/components/MetricsTable.test.tsx +++ b/frontend/src/components/MetricsTable.test.tsx @@ -115,6 +115,17 @@ describe('MetricsTable', () => { expect(within(table).getByText('string')).toBeInTheDocument(); }); + it('colors the type badge of a type that the server capitalizes', async () => { + const [cpu] = sampleMetrics['dcat:dataset']; + mockListMetrics.mockResolvedValue({ + data: { ...sampleMetrics, 'dcat:dataset': [{ ...cpu, 'sensor:type': 'Float' }] }, + }); + render(, { wrapper: createWrapper() }); + + const badge = await within(await screen.findByRole('table')).findByText('float'); + expect(badge).toHaveClass('badge-primary'); + }); + it('selects a metric when clicking a row and updates store', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); diff --git a/frontend/src/components/MetricsTable.tsx b/frontend/src/components/MetricsTable.tsx index dbc89ae6..64ac8fee 100644 --- a/frontend/src/components/MetricsTable.tsx +++ b/frontend/src/components/MetricsTable.tsx @@ -37,8 +37,9 @@ export function MetricsTable() { } } + // The server says `Float`, the filter above `float` function sensorTypeBadgeClass(type: string): string { - switch (type) { + switch (type.toLowerCase()) { case 'float': case 'numeric': return 'badge-primary'; @@ -129,8 +130,9 @@ export function MetricsTable() { }`} onClick={() => handleSelectMetric(metric)} > -
)}
+ Select Series IDTypeLabels + {dimension} + SeriesType + Series ID +
- handleToggle(s)} - onClick={(e) => e.stopPropagation()} - /> - - {s['dct:identifier']} - - - {s['sensor:type']} - - -
- {(s['sensor:labels'] ?? []).map((labelObj, i) => - Object.entries(labelObj).map(([k, v]) => ( - - {k}= - {v} - - )) - )} - {(!s['sensor:labels'] || s['sensor:labels'].length === 0) && ( - no labels - )} +
+ toggleSeries(toSeriesInfo(s))} + onClick={(e) => e.stopPropagation()} + /> +
+ {labels[dimension] ?? —} + no labels + {s['sensor:type']} + + {uuid} +
-
+ {/* Takes what the other columns leave, and cuts the name there (`max-w-0` is what lets it) */} +
+
{name} {metric['sensor:unit'] && ( ({metric['sensor:unit']}) @@ -139,14 +141,14 @@ export function MetricsTable() {
- {metric['sensor:type']} + {metric['sensor:type'].toLowerCase()} {seriesCount ?? '—'} -
+
{dimensions.slice(0, 5).map((dim) => ( {dim} diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index 0058b35e..6c63afaf 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -198,7 +198,7 @@ export function SeriesTable() { )} {mixedTypes && (
- {s['sensor:type']} + {s['sensor:type'].toLowerCase()} Date: Mon, 5 Oct 2026 11:09:48 +0200 Subject: [PATCH 21/53] =?UTF-8?q?docs:=20=F0=9F=93=9D=20the=20third=20fron?= =?UTF-8?q?tend=20review=20round:=20what=20changed,=20what=20was=20checked?= =?UTF-8?q?,=20what=20is=20left?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 8 +++-- .../frontend-review-round-3.md | 30 +++++++++++++++---- ideas/frontend-constant-label-columns.md | 9 ++++++ ideas/frontend-next-steps.md | 8 ++++- 4 files changed, 46 insertions(+), 9 deletions(-) rename {current_tasks => done}/frontend-review-round-3.md (54%) create mode 100644 ideas/frontend-constant-label-columns.md diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index b3ddda4e..d248b474 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -16,7 +16,7 @@ Two selectors next to the time range control what the server computes, as in the - **Step**: `Auto`, `Raw`, or a fixed duration (`5s` … `1d`). `Auto` reads ranges under about 2.8 hours as they are, and above that cuts the range in at most 2 000 buckets of a round duration, because the server refuses more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)). `Raw` on a range that is too wide gets that refusal. - **Aggregation**: `avg`, `min`, `max`, `sum`, `count`, `first`, `last`, applied to the samples of each step. Disabled on `Raw`. -Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. +Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. The chart has no legend of its own: the list of series shows the color of each selected series, which it keeps for as long as it is selected. A series that is selected or unselected comes and goes with an animation, the others and the zoom stay. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. ## Address @@ -26,13 +26,17 @@ The explorer is in the address, so a link shares a view and a reload keeps it: ` | --- | --- | --- | | `metric` | the selected metric | none | | `series` | a selected series, repeated | none | -| `range` | a preset (`15m`, `1h`, `6h`, `24h`, `7d`, `30d`), ending when the page opens | `1h` | +| `range` | a preset (`15m`, `1h`, `6h`, `24h`, `7d`, `30d`), ending now | `1h` | | `from`, `to` | an absolute range (ISO 8601), once dates were typed | a preset is used | | `step`, `agg` | the step (`raw` or a duration) and the aggregation | `auto`, `avg` | | `style`, `log` | the chart style, `log=1` for the log scale | `line`, linear | The address follows the explorer without adding history entries. What is wrong in an address is ignored; a series that no longer exists is dropped. A series is named by its uuid only: the UI asks the server for the first sample of each one (its name, labels and type), and on a server with authentication a link asks for a token like any other page. +A preset range ends now: it moves on every minute while the tab is on screen, and when the tab comes back. Dates that were typed do not move. + +The list of series has a column for each label dimension, and the uuid in small. The Type column is there only when the series of a name are not all of one type: the server groups the metrics by name and type, so a name that exists as a float and as a string is two rows of the metrics list and one list of series. Choosing a metric with at most 8 series (the size of the palette) selects them all, the ones that can be drawn (numbers and booleans); with more, the choice is the user's. + The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. ## Authentication diff --git a/current_tasks/frontend-review-round-3.md b/done/frontend-review-round-3.md similarity index 54% rename from current_tasks/frontend-review-round-3.md rename to done/frontend-review-round-3.md index ff58fc9e..f974e085 100644 --- a/current_tasks/frontend-review-round-3.md +++ b/done/frontend-review-round-3.md @@ -25,9 +25,27 @@ Branch: `frontend-good-enough`. Comments from the user after using the UI. ## Steps -1. [ ] Small cleanups: header count, health badge, metric name width, metric row keys -2. [ ] Live range -3. [ ] Series list: dimension columns, small uuid, type when mixed, colour swatch, auto-select -4. [ ] Chart: no legend, animated toggles, stable colours -5. [ ] Logo, favicon, theme, palette -6. [ ] Docs (`docs/FRONTEND.md`), live check in a browser, move to `done/` +1. [x] Small cleanups: header count, health badge, metric name width, metric row keys +2. [x] Live range (`hooks/useLiveRange.ts`). `keepPreviousData` does not work with `useQueries` (a new key is a + new observer), so the chart keeps the last data of each series itself. +3. [x] Series list: dimension columns, small uuid, type when mixed, colour swatch, auto-select +4. [x] Chart: no legend, animated toggles, stable colours (`lib/palette.ts`) +5. [x] Logo, favicon, theme, palette +6. [x] Docs (`docs/FRONTEND.md`), live check in a browser + +## Results + +Verified in a browser against a live SensApp (SQLite, Influx-written data, dark and light, 1280 and 375 px): +auto-select of a 3-series metric with swatches matching the chart, no auto-select of a 12-series one, +unselecting a series leaves the colours of the others and reselecting gives it its colour back, a zoom +survives toggling, the range moved by itself after a minute, the metrics table does not scroll sideways. +`npm test` (130), lint, typecheck and build pass. + +Found on the way: the server sends `Float` and `String`, so the colours of the metric type badges, chosen on +`float` and `string`, never applied. Fixed. Not looked at: the animation itself (only its result), the dark +logo in the pane at 1280 px. + +Left (in `ideas/`): `frontend-constant-label-columns.md` (the Influx importer adds `influxdb_org` and +`influxdb_bucket` to every series, two useless columns), and more than 8 series at once (`frontend-next-steps.md`). +The original logo files in `frontend/public/` (`sensapp_logo.png`, `_white`, `_small`, `_small_white`) are not +committed: the page uses the `-fs8` ones. diff --git a/ideas/frontend-constant-label-columns.md b/ideas/frontend-constant-label-columns.md new file mode 100644 index 00000000..00006c8e --- /dev/null +++ b/ideas/frontend-constant-label-columns.md @@ -0,0 +1,9 @@ +# Frontend: do not spend a column on a label that never changes + +The series list has one column per label dimension. Data written with the InfluxDB endpoint carries +`influxdb_org` and `influxdb_bucket` on every series (the importer adds them), so two of the columns, and two +chips of every metric row, say the same thing on every row. + +Idea: when every series of the list has the same value for a dimension, say it once above the table +("same for all: influxdb_org=sensapp") instead of a column. Worth doing if people write through Influx +mostly; the other option is to ask whether the importer should add those labels at all. diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md index 34824a5d..9f0ae481 100644 --- a/ideas/frontend-next-steps.md +++ b/ideas/frontend-next-steps.md @@ -12,4 +12,10 @@ What was left out of `done/frontend-good-enough.md` on purpose, most useful firs - **Expression box (PromQL).** Decided against for now: the series browser with step and aggregation covers one series at a time, and the box would only add combining series (`avg by (room)`, through `/api/v1/query`). Revisit when someone asks for that. -- Not planned: dashboards, alerting, a query builder (Grafana's job). Auto-refresh was declined too. +- Not planned: dashboards, alerting, a query builder (Grafana's job). +- **More than 8 series at once.** The colors of the series are eight validated hues and are used again past + eight (a series list of 9 selected has two blue ones). The dataviz guidance says to fold into "Other" or + facet; today the user is trusted not to select that many. A warning past 8, or a small multiples view, if it + turns out to matter. +- **Auto-refresh** was declined at first, but a preset range now follows the clock every minute (the end is now), + which is the same thing for the chart. A refresh switch would only be needed to stop it. From 8ce0dcc1384d998c8693da6ad6f189c3bc510523 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:22:42 +0200 Subject: [PATCH 22/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20one=20tim?= =?UTF-8?q?e=20window,=20moved=20from=20below=20the=20chart,=20zoomed=20by?= =?UTF-8?q?=20dragging=20on=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The echarts zoom (slider, wheel, toolbox) zoomed into points that were cut for the window before, and nothing said how to get back. The window of the explorer is now the only one: a drag on the chart sets it and the data is read again at the step that fits (raw samples when zoomed in), and the bar below the chart has back, earlier, zoom out and later next to the presets, the dates, the step and the aggregation. The history keeps the windows the user left (not what the clock did to a preset). While the new data loads the chart keeps the old points and a thin bar says it, where a line of text moved the chart. Long series names no longer leave the tooltip. Co-Authored-By: Claude Sonnet 5.5 --- current_tasks/frontend-review-round-4.md | 47 +++++++++++ frontend/src/components/EChart.tsx | 35 +++++--- frontend/src/components/Layout.tsx | 2 +- .../src/components/TimeRangeSelector.test.tsx | 41 +++++++++ frontend/src/components/TimeRangeSelector.tsx | 59 ++++++++++++- .../src/components/TimeSeriesChart.test.tsx | 35 ++++++++ frontend/src/components/TimeSeriesChart.tsx | 30 ++++--- frontend/src/hooks/useLiveRange.ts | 2 +- frontend/src/lib/chartOption.test.ts | 13 ++- frontend/src/lib/chartOption.ts | 29 +++++-- frontend/src/lib/timeRange.test.ts | 56 +++++++++++++ frontend/src/lib/timeRange.ts | 41 +++++++++ frontend/src/pages/ExplorerPage.tsx | 10 ++- frontend/src/stores/useSelectionStore.test.ts | 61 ++++++++++++++ frontend/src/stores/useSelectionStore.ts | 84 ++++++++++++++++--- frontend/src/test/setup.tsx | 7 +- 16 files changed, 495 insertions(+), 57 deletions(-) create mode 100644 current_tasks/frontend-review-round-4.md create mode 100644 frontend/src/lib/timeRange.test.ts diff --git a/current_tasks/frontend-review-round-4.md b/current_tasks/frontend-review-round-4.md new file mode 100644 index 00000000..57d36f2d --- /dev/null +++ b/current_tasks/frontend-review-round-4.md @@ -0,0 +1,47 @@ +# Frontend: fourth review round (5 October 2026) + +Branch: `frontend-good-enough`. + +## Time: one window, one place + +The problem: the chart had two ways to choose what it shows. The time range control (presets, dates) decided what is +fetched, and the echarts zoom slider, the wheel and the toolbox zoomed into what was already fetched. Zooming +showed the same coarse points bigger (a day averaged per minute, zoomed on 15 minutes, is 15 points), the slider +showed the very thing the chart shows, and nothing said how to get back. + +Decision: **the time window of the store is the only window.** The chart draws exactly that window. + +- Drag on the chart (a horizontal brush, native echarts) = zoom: it sets the window, the data is fetched again at + the step that fits it (a few seconds of raw samples when zoomed in). No slider, no inside zoom, no wheel (the + wheel scrolls the page on small screens). +- Navigation below the chart, where the thumb and the eyes are after looking at it: `‹` `›` pan by half a window, + `−` zooms out (twice the window, or the next preset for a live range), `↶` goes back to the window before (a + history of the last 20 windows made by the user, not by the clock), then the presets, the dates, the step and the + aggregation. The chart header keeps what is about the drawing (style, log). +- While the new data loads the chart keeps the old points on the new axis (an instant, coarse zoom that + sharpens), with a thin progress bar on top instead of a line of text that moves the chart. +- Not done: an overview of the whole life of the series in place of the slider (`/series/{uuid}/availability` + could feed it). It would be a third thing to look at; written in `ideas/`. + +## Smaller + +- Header: same horizontal padding as the cards so the logo lines up with them. The image has no padding of its + own: what looks like space is the pale dish. +- Series list sorted by the first label column, then the next ones, numbers in natural order (`node-2` before + `node-10`); a click on a column header sorts by it. The server pages by creation order, so the sort is of the + page on screen. +- The placeholder of the selector is made of the labels of the first series. +- The checkbox takes the color of the series (no more dot). +- Labels that are the same on every series are said once above the table, not in a column (Influx org and bucket, + which the importer adds on purpose: they are part of the identity of a series). +- More than 8 series: 24 colors, the validated 8 then 16 chosen by farthest-point sampling in OKLab under normal + and color-blind vision; hovering a row highlights its series in the chart, because past 8 color alone does not + say which is which. + +## Steps + +1. [ ] Header alignment +2. [ ] Time: store (history, pan, zoom out), the bar below the chart, brush zoom, progress bar +3. [ ] Series list: sort, placeholder, colored checkbox, shared labels +4. [ ] 24 colors, hover highlight +5. [ ] Docs, live check, done/ diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx index 4f8c556e..aeaf5376 100644 --- a/frontend/src/components/EChart.tsx +++ b/frontend/src/components/EChart.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import * as echarts from 'echarts/core'; import { BarChart, LineChart } from 'echarts/charts'; import { - DataZoomComponent, + BrushComponent, GridComponent, ToolboxComponent, TooltipComponent, @@ -14,7 +14,7 @@ import { usePrefersDark } from '../lib/usePrefersDark'; echarts.use([ BarChart, LineChart, - DataZoomComponent, + BrushComponent, GridComponent, ToolboxComponent, TooltipComponent, @@ -24,25 +24,36 @@ echarts.use([ /** * Minimal echarts wrapper: one instance per mount, resized with its container. A new option is merged * into what is drawn, so that a series that is added or removed comes and goes with an animation - * while the others stay (and the zoom with them). `resetZoomKey` is what the zoom belongs to. + * while the others stay. A drag on the chart is a window: `onBrush` gets its two ends, in ms. */ export default function EChart({ option, - resetZoomKey, + onBrush, }: { option: echarts.EChartsCoreOption; - resetZoomKey?: string; + onBrush?: (fromMs: number, toMs: number) => void; }) { const container = useRef(null); const chart = useRef(null); const dark = usePrefersDark(); + const onBrushRef = useRef(onBrush); + useEffect(() => { + onBrushRef.current = onBrush; + }); + // A new instance when the theme changes: echarts cannot switch it on a live one useEffect(() => { const element = container.current; if (!element) return; const instance = echarts.init(element, dark ? 'dark' : undefined); chart.current = instance; + // A drag draws a window, which is asked for and then wiped: the chart shows the new one + instance.on('brushEnd', (event) => { + const range = (event as { areas?: Array<{ coordRange?: number[] }> }).areas?.[0]?.coordRange; + if (range && range.length === 2) onBrushRef.current?.(range[0], range[1]); + instance.dispatchAction({ type: 'brush', areas: [] }); + }); const observer = new ResizeObserver(() => instance.resize()); observer.observe(element); return () => { @@ -53,17 +64,21 @@ export default function EChart({ }, [dark]); useEffect(() => { + const instance = chart.current; + if (!instance) return; // The theme brings a background of its own: the card has one already - chart.current?.setOption( + instance.setOption( { backgroundColor: 'transparent', ...option }, // What a merge would keep of a series or an axis that is gone { replaceMerge: ['series', 'yAxis'] }, ); + // Dragging selects a window, with no button to press first + instance.dispatchAction({ + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { brushType: 'lineX', brushMode: 'single' }, + }); }, [option, dark]); - useEffect(() => { - chart.current?.dispatchAction({ type: 'dataZoom', start: 0, end: 100 }); - }, [resetZoomKey]); - return
; } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5e0c07ca..8d00e8cc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -13,7 +13,7 @@ export function Layout() { return (
-
+
diff --git a/frontend/src/components/TimeRangeSelector.test.tsx b/frontend/src/components/TimeRangeSelector.test.tsx index 4686ef00..a819e0b4 100644 --- a/frontend/src/components/TimeRangeSelector.test.tsx +++ b/frontend/src/components/TimeRangeSelector.test.tsx @@ -91,4 +91,45 @@ describe('TimeRangeSelector', () => { expect(useSelectionStore.getState().relativeRange).toBeNull(); expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'false'); }); + + describe('moving the window', () => { + const typed = { start: '2026-10-04T10:00:00.000Z', end: '2026-10-04T12:00:00.000Z' }; + const range = () => useSelectionStore.getState().timeRange; + + beforeEach(() => { + useSelectionStore.setState({ rangeHistory: [], relativeRange: null, timeRange: typed }); + }); + + it('goes earlier and later by half a window', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Earlier' })); + expect(range()).toEqual({ start: '2026-10-04T09:00:00.000Z', end: '2026-10-04T11:00:00.000Z' }); + await user.click(screen.getByRole('button', { name: 'Later' })); + expect(range()).toEqual(typed); + }); + + it('zooms out, and goes back', async () => { + const user = userEvent.setup(); + render(); + const back = screen.getByRole('button', { name: 'Back to the previous window' }); + expect(back).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Zoom out' })); + expect(range()).toEqual({ start: '2026-10-04T09:00:00.000Z', end: '2026-10-04T13:00:00.000Z' }); + expect(back).toBeEnabled(); + await user.click(back); + expect(range()).toEqual(typed); + expect(back).toBeDisabled(); + }); + + it('has no later for a live window: it is at now', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: '1h' })); + expect(screen.getByRole('button', { name: 'Later' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Earlier' })).toBeEnabled(); + }); + }); }); diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index 74a9bc89..e785aca1 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -4,8 +4,20 @@ import { PRESETS } from '../lib/timeRange'; import { useSelectionStore } from '../stores/useSelectionStore'; export function TimeRangeSelector() { - const { timeRange, relativeRange, setTimeRange, setRelativeRange, step, setStep, aggregation, setAggregation } = - useSelectionStore(); + const { + timeRange, + relativeRange, + rangeHistory, + setTimeRange, + setRelativeRange, + panRange, + zoomOutRange, + undoRange, + step, + setStep, + aggregation, + setAggregation, + } = useSelectionStore(); function handleStartChange(value: string) { if (value) { @@ -27,7 +39,46 @@ export function TimeRangeSelector() { } return ( -
+
+ {/* Where the window is: back to the one before, earlier, wider, later */} +
+ + + + +
+
{PRESETS.map((preset) => (
-
+
0 ? columns : dimensions)} className="input input-bordered input-xs font-mono text-xs flex-1 min-w-44 max-w-xs h-7" value={selectorInput} onChange={(e) => { @@ -138,18 +157,42 @@ export function SeriesTable() { {!isLoading && !error && filteredSeries.length > 0 && (
+ {Object.keys(shared).length > 0 && ( +
+ on every series + {Object.entries(shared).map(([key, value]) => ( + + {key}= + {value} + + ))} +
+ )} - {dimensions.map((dimension) => ( - ))} - {dimensions.length === 0 && } + {columns.length === 0 && } {mixedTypes && } - {filteredSeries.map((s) => { + {sortedSeries.map((s) => { const uuid = s['dct:identifier']; const selected = selectedOf(s); const labels = labelsToRecord(s['sensor:labels']); @@ -172,29 +215,31 @@ export function SeriesTable() { onClick={() => toggleSeries(toSeriesInfo(s))} > - {dimensions.map((dimension) => ( - ))} - {dimensions.length === 0 && ( - + {columns.length === 0 && ( + )} {mixedTypes && ( {columns.map((column) => ( @@ -148,9 +148,9 @@ export function MetricsTable() { {seriesCount ?? '—'} )}
Select - {dimension} + {columns.map((column) => ( + + SeriesSeriesType Series ID @@ -157,7 +200,7 @@ export function SeriesTable() {
-
- toggleSeries(toSeriesInfo(s))} - onClick={(e) => e.stopPropagation()} - /> - -
+ {/* The checkbox is the color of the series in the chart */} + toggleSeries(toSeriesInfo(s))} + onClick={(e) => e.stopPropagation()} + />
- {labels[dimension] ?? —} + {columns.map((column) => ( + + {labels[column] ?? —} no labels{dimensions.length === 0 ? 'no labels' : '—'} diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index 3de8978d..af2d9434 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -245,4 +245,21 @@ describe('TimeSeriesChart', () => { expect(await screen.findByRole('progressbar', { name: 'Loading chart data' })).toBeInTheDocument(); expect(screen.getByTestId('echarts')).toBeInTheDocument(); }); + + it('names a series by what tells it from the others', async () => { + const lab = { ...temperature, uuid: 'uuid-lab', labels: { org: 'sensapp', room: 'lab' } }; + const office = { ...temperature, uuid: 'uuid-office', slot: 1, labels: { org: 'sensapp', room: 'office' } }; + useSelectionStore.setState({ selectedSeries: [lab, office], timeRange: { start: START, end: hours(1) } }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '2')); + const names = JSON.parse(chart.getAttribute('data-option')!).series.map((s: { name: string }) => s.name); + expect(names).toEqual(['temperature{room="lab"}', 'temperature{room="office"}']); + + // Alone, it says all it has + act(() => useSelectionStore.setState({ selectedSeries: [lab] })); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + expect(JSON.parse(chart.getAttribute('data-option')!).series[0].name).toBe('temperature{org="sensapp", room="lab"}'); + }); }); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index ff825aef..9709774f 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -6,6 +6,7 @@ import { unwrap } from '../api/clientConfig'; import { isBooleanType, isNumericType, resolveStep } from '../lib/chartStep'; import { buildChartOption } from '../lib/chartOption'; import { seriesColor } from '../lib/palette'; +import { sharedLabels, withoutShared } from '../lib/seriesLabels'; import { brushedRange } from '../lib/timeRange'; import { usePrefersDark } from '../lib/usePrefersDark'; @@ -95,12 +96,14 @@ export function TimeSeriesChart() { } const option = useMemo(() => { + // What every series has is not what tells them apart + const shared = sharedLabels(selectedSeries.map((s) => s.labels)); const series = selectedSeries .filter((s) => shown[s.uuid]) .map((s) => { return { id: s.uuid, - name: buildSeriesLabel(s.name, s.labels), + name: buildSeriesLabel(s.name, withoutShared(s.labels, shared)), points: parseSenMLToTimeSeries(shown[s.uuid].records), boolean: isBooleanType(s.type), color: seriesColor(s.slot, dark), diff --git a/frontend/src/lib/palette.test.ts b/frontend/src/lib/palette.test.ts index 7e4952a0..c45f1207 100644 --- a/frontend/src/lib/palette.test.ts +++ b/frontend/src/lib/palette.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { freeSlot, SERIES_COLORS, seriesColor, SLOTS } from './palette'; +import { freeSlot, readableOn, SERIES_COLORS, seriesColor, SLOTS } from './palette'; describe('palette', () => { it('has a step of each hue for each theme', () => { @@ -19,4 +19,9 @@ describe('palette', () => { expect(freeSlot(all)).toBe(0); expect(seriesColor(SLOTS + 1, false)).toBe(seriesColor(1, false)); }); + + it('writes in dark on the light colors and in white on the others', () => { + expect(readableOn('#eda100')).toBe('#111827'); + expect(readableOn('#2a78d6')).toBe('#ffffff'); + }); }); diff --git a/frontend/src/lib/palette.ts b/frontend/src/lib/palette.ts index a7f5d404..075c1d5f 100644 --- a/frontend/src/lib/palette.ts +++ b/frontend/src/lib/palette.ts @@ -27,3 +27,9 @@ export function freeSlot(used: number[]): number { } return used.length % SLOTS; } + +/** The ink of a tick on a color: dark on the light colors (the yellow), white on the others. */ +export function readableOn(hex: string): string { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? '#111827' : '#ffffff'; +} diff --git a/frontend/src/lib/seriesLabels.test.ts b/frontend/src/lib/seriesLabels.test.ts new file mode 100644 index 00000000..3131500d --- /dev/null +++ b/frontend/src/lib/seriesLabels.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { exampleSelector, sharedLabels, sortByColumns, withoutShared } from './seriesLabels'; + +describe('sharedLabels', () => { + it('keeps what every series has the same, nothing for one series', () => { + const list = [ + { org: 'a', room: 'lab' }, + { org: 'a', room: 'office' }, + ]; + expect(sharedLabels(list)).toEqual({ org: 'a' }); + expect(sharedLabels([list[0]])).toEqual({}); + expect(sharedLabels([])).toEqual({}); + }); + + it('is not shared when one series lacks the label', () => { + expect(sharedLabels([{ org: 'a' }, { org: 'a' }, {}])).toEqual({}); + }); + + it('takes the shared labels out of one series', () => { + expect(withoutShared({ org: 'a', room: 'lab' }, { org: 'a' })).toEqual({ room: 'lab' }); + }); +}); + +describe('sortByColumns', () => { + const rows = [ + { host: 'node-10', dc: 'b' }, + { host: 'node-2', dc: 'b' }, + { host: 'node-1', dc: 'a' }, + { dc: 'a' }, + ]; + const sort = (first?: string, descending = false) => + sortByColumns(rows, (r) => r, ['host', 'dc'], first, descending).map((r) => `${r.host ?? '-'}/${r.dc}`); + + it('orders by the first column, numbers as numbers, what lacks it last', () => { + expect(sort()).toEqual(['node-1/a', 'node-2/b', 'node-10/b', '-/a']); + }); + + it('orders by the column that was chosen, then by the others', () => { + expect(sort('dc')).toEqual(['node-1/a', '-/a', 'node-2/b', 'node-10/b']); + }); + + it('can be reversed, what lacks the label staying last', () => { + expect(sort('host', true)).toEqual(['node-10/b', 'node-2/b', 'node-1/a', '-/a']); + }); + + it('leaves its input alone', () => { + sort(); + expect(rows[0].host).toBe('node-10'); + }); +}); + +describe('exampleSelector', () => { + it('takes two labels of the series, the second as a start', () => { + expect(exampleSelector({ room: 'lab', floor: '1', other: 'x' }, ['room', 'floor', 'other'])).toBe( + '{room="lab", floor=~"1.*"}', + ); + }); + + it('works with one label, and with a series that has none', () => { + expect(exampleSelector({ room: 'lab' }, ['room'])).toBe('{room="lab"}'); + expect(exampleSelector({}, [])).toBe('{label="value", other=~"va.*"}'); + expect(exampleSelector(undefined, ['room'])).toBe('{label="value", other=~"va.*"}'); + }); + + it('skips a label the series does not have, and escapes what has to be', () => { + expect(exampleSelector({ b: 'say "hi"', c: '.x' }, ['a', 'b', 'c'])).toBe(String.raw`{b="say \"hi\"", c=~"\\..*"}`); + }); +}); diff --git a/frontend/src/lib/seriesLabels.ts b/frontend/src/lib/seriesLabels.ts new file mode 100644 index 00000000..4fdbe539 --- /dev/null +++ b/frontend/src/lib/seriesLabels.ts @@ -0,0 +1,67 @@ +export type Labels = Record; + +/** `node-2` before `node-10`, and `A` next to `a`. */ +export function naturalCompare(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); +} + +/** + * The labels that have the same value on every series, which is nothing for a single series (it would + * say everything it has). Said once above a list, they would only repeat in a column. + */ +export function sharedLabels(all: Labels[]): Labels { + if (all.length < 2) return {}; + const [first, ...others] = all; + return Object.fromEntries( + Object.entries(first).filter(([key, value]) => others.every((labels) => labels[key] === value)), + ); +} + +/** The labels of a series that tell it from the others of a selection. */ +export function withoutShared(labels: Labels, shared: Labels): Labels { + return Object.fromEntries(Object.entries(labels).filter(([key]) => !(key in shared))); +} + +/** + * Series ordered by the columns in turn: the one that was clicked first, then the others in their + * order, a missing label coming last. Numbers inside the values are read as numbers. + */ +export function sortByColumns( + items: T[], + labelsOf: (item: T) => Partial, + columns: string[], + first: string | undefined, + descending: boolean, +): T[] { + const order = first ? [first, ...columns.filter((c) => c !== first)] : columns; + return [...items].sort((a, b) => { + for (const [index, column] of order.entries()) { + const left = labelsOf(a)[column]; + const right = labelsOf(b)[column]; + if (left === right) continue; + if (left === undefined) return 1; + if (right === undefined) return -1; + const result = naturalCompare(left, right); + if (result !== 0) return index === 0 && descending ? -result : result; + } + return 0; + }); +} + +const quote = (value: string) => `"${value.replace(/[\\"]/g, '\\$&')}"`; + +/** + * A selector made of what the series have, to show how to write one: the first label equals its value, + * the second starts like its value (`{room="lab", floor=~"1.*"}`). + */ +export function exampleSelector(labels: Labels | undefined, columns: string[]): string { + const known = columns.filter((column) => labels?.[column] !== undefined).slice(0, 2); + if (!labels || known.length === 0) return '{label="value", other=~"va.*"}'; + const [one, two] = known; + const parts = [`${one}=${quote(labels[one])}`]; + if (two) { + const start = labels[two].slice(0, 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + parts.push(`${two}=~${quote(`${start}.*`)}`); + } + return `{${parts.join(', ')}}`; +} From e90a6b6ecd01f98b87c74d3ec6d80aca14aa3fed Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:28:13 +0200 Subject: [PATCH 24/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=2024=20colo?= =?UTF-8?q?rs,=20a=20highlight=20on=20hover,=20select=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More than 8 series no longer repeat a color: the first 8 are the validated palette, the 16 after them are the farthest from the ones before under normal and color-blind vision (the dark theme needs lighter steps than the validated band for that). Past 8 a color does not say which line is which, so hovering a row of the list makes its line stand out and fades the others. The header of the list has a checkbox that selects, or unselects, what is on screen. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/components/EChart.tsx | 10 ++++ frontend/src/components/SeriesTable.test.tsx | 52 ++++++++++++++++++- frontend/src/components/SeriesTable.tsx | 26 ++++++++-- .../src/components/TimeSeriesChart.test.tsx | 11 ++++ frontend/src/components/TimeSeriesChart.tsx | 3 +- frontend/src/lib/chartOption.ts | 3 ++ frontend/src/lib/palette.test.ts | 15 +++++- frontend/src/lib/palette.ts | 33 +++++++++--- frontend/src/stores/useSelectionStore.ts | 15 +++++- frontend/src/test/setup.tsx | 3 +- 10 files changed, 154 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx index aeaf5376..25018d53 100644 --- a/frontend/src/components/EChart.tsx +++ b/frontend/src/components/EChart.tsx @@ -29,9 +29,12 @@ echarts.use([ export default function EChart({ option, onBrush, + highlight, }: { option: echarts.EChartsCoreOption; onBrush?: (fromMs: number, toMs: number) => void; + /** The id of a series to stand out, the others fading */ + highlight?: string | null; }) { const container = useRef(null); const chart = useRef(null); @@ -80,5 +83,12 @@ export default function EChart({ }); }, [option, dark]); + useEffect(() => { + const instance = chart.current; + if (!instance) return; + instance.dispatchAction({ type: 'downplay' }); + if (highlight) instance.dispatchAction({ type: 'highlight', seriesId: highlight }); + }, [highlight, option, dark]); + return
; } diff --git a/frontend/src/components/SeriesTable.test.tsx b/frontend/src/components/SeriesTable.test.tsx index ecd9e75d..6c8d1f0e 100644 --- a/frontend/src/components/SeriesTable.test.tsx +++ b/frontend/src/components/SeriesTable.test.tsx @@ -160,9 +160,58 @@ describe('SeriesTable', () => { expect(screen.getByPlaceholderText('{host="alpha", room=~"l.*"}')).toBeInTheDocument(); }); + it('tells the chart which series the pointer is on, and forgets it when it leaves', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-a'); + const row = screen.getByText('uuid-b').closest('tr')!; + + await user.hover(row); + expect(useSelectionStore.getState().hoveredSeries).toBe('uuid-b'); + await user.unhover(row); + expect(useSelectionStore.getState().hoveredSeries).toBeNull(); + }); + describe('selection', () => { const uuids = () => useSelectionStore.getState().selectedSeries.map((s) => s.uuid); + it('selects, and unselects, what is on screen from the header', async () => { + const user = userEvent.setup(); + const many = Array.from({ length: 10 }, (_, i) => dataset(`uuid-${i}`, `host-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + await screen.findByText('uuid-0'); + const all = screen.getByRole('checkbox', { name: 'Select all series' }); + expect(all).not.toBeChecked(); + + await user.click(all); + expect(uuids()).toHaveLength(10); + // Ten series, ten colors: none repeated + expect(new Set(useSelectionStore.getState().selectedSeries.map((s) => s.slot)).size).toBe(10); + expect(all).toBeChecked(); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-3' })); + expect(all).not.toBeChecked(); + expect((all as HTMLInputElement).indeterminate).toBe(true); + + await user.click(all); + expect(uuids()).toHaveLength(10); + await user.click(all); + expect(uuids()).toEqual([]); + }); + + it('selects only what the quick filter lets through', async () => { + const user = userEvent.setup(); + const many = Array.from({ length: 10 }, (_, i) => dataset(`uuid-${i}`, i < 3 ? `lab-${i}` : `other-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + await screen.findByText('uuid-0'); + + await user.type(screen.getByPlaceholderText('Quick filter...'), 'lab'); + await user.click(screen.getByRole('checkbox', { name: 'Select all series' })); + expect(uuids().sort()).toEqual(['uuid-0', 'uuid-1', 'uuid-2']); + }); + it('selects all the series of a metric that is not too big, with a color each', async () => { renderTable(); @@ -175,7 +224,8 @@ describe('SeriesTable', () => { slot: 0, }); // The checkbox of a series has the color of its line - const [first, second] = screen.getAllByRole('checkbox'); + const first = screen.getByRole('checkbox', { name: 'Select series uuid-a' }); + const second = screen.getByRole('checkbox', { name: 'Select series uuid-b' }); expect(first).toBeChecked(); expect(first.style.getPropertyValue('--input-color')).toBe(SERIES_COLORS.light[0]); expect(second.style.getPropertyValue('--input-color')).toBe(SERIES_COLORS.light[1]); diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index df6948a9..59baa3c7 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from 'react'; import { nextBookmark, useSeries } from '../hooks/useSeries'; import type { SeriesDataset } from '../hooks/useSeries'; import { isBooleanType, isNumericType } from '../lib/chartStep'; -import { readableOn, seriesColor, SLOTS } from '../lib/palette'; +import { DISTINCT_COLORS, readableOn, seriesColor } from '../lib/palette'; import { exampleSelector, sharedLabels, sortByColumns } from '../lib/seriesLabels'; import { usePrefersDark } from '../lib/usePrefersDark'; import { useSelectionStore } from '../stores/useSelectionStore'; @@ -20,8 +20,8 @@ function labelsToRecord( ); } -/** A metric of at most this many series is shown whole when it is selected: there is a color for each. */ -const AUTO_SELECT_MAX = SLOTS; +/** A metric of at most this many series is shown whole when it is selected: they have colors that are clearly apart. */ +const AUTO_SELECT_MAX = DISTINCT_COLORS; function toSeriesInfo(dataset: SeriesDataset): SeriesInfo { return { @@ -38,6 +38,8 @@ export function SeriesTable() { selectedSeries, toggleSeries, selectSeries, + unselectSeries, + setHoveredSeries, labelFilter, setLabelFilter, } = useSelectionStore(); @@ -111,6 +113,8 @@ export function SeriesTable() { sort.descending, ); + const visibleSelected = sortedSeries.filter((s) => selectedOf(s)).length; + if (!selectedMetric) { return null; } @@ -172,7 +176,19 @@ export function SeriesTable() {
- Select + {/* What is on screen, all at once: after the filter, or the selector, or on its own */} + { + if (box) box.indeterminate = visibleSelected > 0 && visibleSelected < sortedSeries.length; + }} + checked={visibleSelected === sortedSeries.length} + onChange={() => + visibleSelected === sortedSeries.length ? unselectSeries(sortedSeries.map(toSeriesInfo)) : selectSeries(sortedSeries.map(toSeriesInfo)) + } + /> toggleSeries(toSeriesInfo(s))} + onMouseEnter={() => setHoveredSeries(uuid)} + onMouseLeave={() => setHoveredSeries(null)} > {/* The checkbox is the color of the series in the chart */} diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx index af2d9434..1e882bea 100644 --- a/frontend/src/components/TimeSeriesChart.test.tsx +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -262,4 +262,15 @@ describe('TimeSeriesChart', () => { await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); expect(JSON.parse(chart.getAttribute('data-option')!).series[0].name).toBe('temperature{org="sensapp", room="lab"}'); }); + + it('highlights the series that the pointer is on in the list', async () => { + useSelectionStore.setState({ selectedSeries: [temperature], timeRange: { start: START, end: hours(1) }, hoveredSeries: null }); + renderChart(); + const chart = await screen.findByTestId('echarts'); + expect(chart).not.toHaveAttribute('data-highlight'); + + act(() => useSelectionStore.getState().setHoveredSeries('uuid-temperature')); + expect(screen.getByTestId('echarts')).toHaveAttribute('data-highlight', 'uuid-temperature'); + act(() => useSelectionStore.getState().setHoveredSeries(null)); + }); }); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index 9709774f..2f549967 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -60,6 +60,7 @@ interface Loaded { export function TimeSeriesChart() { const { selectedSeries, timeRange, setTimeRange, step: stepChoice, aggregation, chartStyle, logScale } = useSelectionStore(); + const hoveredSeries = useSelectionStore((state) => state.hoveredSeries); const dark = usePrefersDark(); const step = resolveStep(stepChoice, timeRange.start, timeRange.end); @@ -139,7 +140,7 @@ export function TimeSeriesChart() { /> )} - + diff --git a/frontend/src/lib/chartOption.ts b/frontend/src/lib/chartOption.ts index 2f3fea5a..ba6cdcc7 100644 --- a/frontend/src/lib/chartOption.ts +++ b/frontend/src/lib/chartOption.ts @@ -31,6 +31,7 @@ export function buildChartOption( step: 'end' as const, yAxisIndex: 1, data: s.points, + emphasis: { focus: 'series' as const }, showSymbol: false, lineStyle: { width: 1.5 }, color: s.color, @@ -41,6 +42,8 @@ export function buildChartOption( name: s.name, type: style === 'bars' ? ('bar' as const) : ('line' as const), data: s.points, + // The series under the pointer in the list stands out, the others fade (EChart sends it) + emphasis: { focus: 'series' as const }, smooth: false, step: style === 'step' ? ('end' as const) : undefined, stack: style === 'stacked' ? 'total' : undefined, diff --git a/frontend/src/lib/palette.test.ts b/frontend/src/lib/palette.test.ts index c45f1207..896b6a12 100644 --- a/frontend/src/lib/palette.test.ts +++ b/frontend/src/lib/palette.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { freeSlot, readableOn, SERIES_COLORS, seriesColor, SLOTS } from './palette'; +import { DISTINCT_COLORS, freeSlot, readableOn, SERIES_COLORS, seriesColor, SLOTS } from './palette'; describe('palette', () => { it('has a step of each hue for each theme', () => { @@ -24,4 +24,17 @@ describe('palette', () => { expect(readableOn('#eda100')).toBe('#111827'); expect(readableOn('#2a78d6')).toBe('#ffffff'); }); + + it('has more colors than eight, none twice, so that more than eight series do not repeat one', () => { + expect(SLOTS).toBeGreaterThan(DISTINCT_COLORS); + expect(new Set(SERIES_COLORS.light).size).toBe(SLOTS); + expect(new Set(SERIES_COLORS.dark).size).toBe(SLOTS); + for (const color of [...SERIES_COLORS.light, ...SERIES_COLORS.dark]) expect(color).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('gives each of more than eight series a color of its own', () => { + const slots: number[] = []; + for (let i = 0; i < SLOTS; i++) slots.push(freeSlot(slots)); + expect(new Set(slots.map((slot) => seriesColor(slot, false))).size).toBe(SLOTS); + }); }); diff --git a/frontend/src/lib/palette.ts b/frontend/src/lib/palette.ts index 075c1d5f..58f23070 100644 --- a/frontend/src/lib/palette.ts +++ b/frontend/src/lib/palette.ts @@ -1,15 +1,31 @@ /** - * The colors of the series, in the order they are given: eight hues, one step for each theme (the - * steps of the light theme are too light on the dark surface). Checked with the dataviz skill's - * `validate_palette.js`: worst neighbours 9.1 (light) and 8.4 (dark) of color-blind distance, 19+ for - * normal vision, 3:1 on the dark surface. On the light surface three are under 3:1: the series list - * says which color is which, so a color is never the only way to tell the series apart. + * The colors of the series, in the order they are given, one step of each for each theme. + * + * The first `DISTINCT_COLORS` are the eight hues of the dataviz skill's palette, validated with its + * `validate_palette.js` (color-blind distance 8.4 or more between neighbours, 19 for normal vision). + * The 16 after them are not a palette but the answer to "more than 8 series without repeating a color": + * each was picked, in OKLab, as the farthest from the ones before under normal, protan and deutan + * vision (docs/FRONTEND.md has the numbers). The ninth to eleventh are 11 (light) and 10 (dark) or more + * apart from the others; from the twelfth it goes down to 5: past eight colors alone does not say + * which series is which, hovering a row of the list highlights it in the chart for that. + * The dark steps are lighter than the skill's band for the same reason: there is no room in it. */ export const SERIES_COLORS = { - light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'], - dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'], + light: [ + '#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948', + '#58aefb', '#803a5b', '#ac5291', '#3d94e0', '#495e0d', '#5bc971', '#02aec6', '#c93a63', + '#324fcf', '#6b4988', '#1e86fe', '#1987bb', '#2bc4d1', '#b86e94', '#6f51ac', '#993f43', + ], + dark: [ + '#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767', + '#65d8e3', '#fab72a', '#5ae297', '#b736a0', '#41b7c7', '#226dc9', '#70bffe', '#fa7ba7', + '#e6a05a', '#b072b5', '#c03978', '#5cacea', '#e46894', '#c74a49', '#3a70ee', '#4ac777', + ], }; +/** The colors that are clearly apart (the validated ones). */ +export const DISTINCT_COLORS = 8; + export const SLOTS = SERIES_COLORS.light.length; export function seriesColor(slot: number, dark: boolean): string { @@ -19,7 +35,8 @@ export function seriesColor(slot: number, dark: boolean): string { /** * The slot of a series that is selected: the first one nobody uses, so that a color follows its - * series for as long as it is selected. Past `SLOTS` series the colors are used again. + * series for as long as it is selected. Past `SLOTS` (24) series the colors are used again: nobody + * reads a chart of 25 lines by color. */ export function freeSlot(used: number[]): number { for (let slot = 0; slot < SLOTS; slot++) { diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index 8e0e8fea..396323ec 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -39,6 +39,8 @@ export function withSlots(series: SeriesInfo[], selected: SelectedSeries[] = []) interface SelectionState { selectedMetric: string | null; selectedSeries: SelectedSeries[]; + /** The series under the pointer in the list, which the chart highlights */ + hoveredSeries: string | null; timeRange: TimeRange; /** The preset the range comes from (`24h`), `null` once dates were typed */ relativeRange: string | null; @@ -54,7 +56,10 @@ interface SelectionState { toggleSeries: (series: SeriesInfo) => void; /** Shows these series too */ selectSeries: (series: SeriesInfo[]) => void; + /** Hides these series, the others keep their colors */ + unselectSeries: (series: SeriesInfo[]) => void; clearSelectedSeries: () => void; + setHoveredSeries: (uuid: string | null) => void; /** A window of dates: typed, or brushed on the chart */ setTimeRange: (start: string, end: string) => void; /** A preset: the range ending now */ @@ -90,6 +95,7 @@ function remember(state: SelectionState): WindowState[] { export const useSelectionStore = create((set) => ({ selectedMetric: null, selectedSeries: [], + hoveredSeries: null, timeRange: defaultTimeRange(), relativeRange: DEFAULT_RANGE, rangeHistory: [], @@ -100,7 +106,7 @@ export const useSelectionStore = create((set) => ({ aggregation: 'avg', setSelectedMetric: (metric) => - set({ selectedMetric: metric, selectedSeries: [] }), + set({ selectedMetric: metric, selectedSeries: [], hoveredSeries: null }), toggleSeries: (series) => set((state) => { @@ -118,8 +124,15 @@ export const useSelectionStore = create((set) => ({ selectSeries: (series) => set((state) => ({ selectedSeries: withSlots(series, state.selectedSeries) })), + unselectSeries: (series) => + set((state) => ({ + selectedSeries: state.selectedSeries.filter((s) => !series.some((gone) => gone.uuid === s.uuid)), + })), + clearSelectedSeries: () => set({ selectedSeries: [] }), + setHoveredSeries: (hoveredSeries) => set({ hoveredSeries }), + setTimeRange: (start, end) => set((state) => ({ timeRange: { start, end }, diff --git a/frontend/src/test/setup.tsx b/frontend/src/test/setup.tsx index 27f08757..d160f866 100644 --- a/frontend/src/test/setup.tsx +++ b/frontend/src/test/setup.tsx @@ -2,13 +2,14 @@ import '@testing-library/jest-dom/vitest'; // echarts needs a canvas, which jsdom does not have. vi.mock('../components/EChart', () => ({ - default: (props: { option: unknown; onBrush?: (fromMs: number, toMs: number) => void }) => { + default: (props: { option: unknown; onBrush?: (fromMs: number, toMs: number) => void; highlight?: string | null }) => { const series = (props.option as { series?: unknown[] })?.series; return (
Chart {/* A drag on the chart, from 00:10 to 00:20 on 2026-10-04 */} From a1a4935de73ce1f9fb1d619f3d28a63d40fe5868 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:28:13 +0200 Subject: [PATCH 25/53] =?UTF-8?q?docs:=20=F0=9F=93=9D=20the=20fourth=20fro?= =?UTF-8?q?ntend=20review=20round:=20the=20time=20window,=20the=20colors,?= =?UTF-8?q?=20the=20importer=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 16 ++++++++++-- .../frontend-review-round-4.md | 25 +++++++++++++++---- ideas/frontend-constant-label-columns.md | 9 ------- ideas/frontend-next-steps.md | 12 ++++++--- ideas/influxdb-labels-without-tags.md | 9 +++++++ 5 files changed, 51 insertions(+), 20 deletions(-) rename {current_tasks => done}/frontend-review-round-4.md (67%) delete mode 100644 ideas/frontend-constant-label-columns.md create mode 100644 ideas/influxdb-labels-without-tags.md diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d248b474..ec061768 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -16,7 +16,19 @@ Two selectors next to the time range control what the server computes, as in the - **Step**: `Auto`, `Raw`, or a fixed duration (`5s` … `1d`). `Auto` reads ranges under about 2.8 hours as they are, and above that cuts the range in at most 2 000 buckets of a round duration, because the server refuses more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)). `Raw` on a range that is too wide gets that refusal. - **Aggregation**: `avg`, `min`, `max`, `sum`, `count`, `first`, `last`, applied to the samples of each step. Disabled on `Raw`. -Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. The chart has no legend of its own: the list of series shows the color of each selected series, which it keeps for as long as it is selected. A series that is selected or unselected comes and goes with an animation, the others and the zoom stay. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. +Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. The chart has no legend of its own: the list of series shows the color of each selected series, which it keeps for as long as it is selected. A series that is selected or unselected comes and goes with an animation, the others stay. Hovering a row of the list makes its series stand out in the chart. + +### The time window + +There is one time window, the one of the explorer, and the chart draws exactly that. The chart has no zoom of its own (a zoom on points that were cut for another window shows the same coarse points bigger): + +- **Drag on the chart** to choose a window: it is asked of the server again, at the step that fits it (raw samples when zoomed in far enough). The old points stay, on the new axis, until the new ones arrive; a thin bar over the chart says it is loading. +- **Below the chart**: `↶` goes back to the window before (the last 20 windows the user left; what the clock does to a preset is not one), `‹` and `›` move by half a window (`›` never goes past now, and is off on a live preset), `−` shows twice the window (a live preset goes to the next one), then the presets, the dates, the step and the aggregation. +- A preset window is **live**: its end is now, and moves every minute while the tab is on screen. A window that was dragged, moved or typed is not. + +### Colors + +The first 8 series have the eight hues of the dataviz palette, validated for color-blind readers (neighbours are 8.4 or more apart in OKLab ×100 under protan and deutan vision, 19 or more with normal vision). The 16 colors after them are there so that no series repeats a color: each was chosen as the farthest, under normal and color-blind vision, from the ones before it. They are not equally good: the 9th to 11th are 10 or more from the others, from the 12th it goes down to 5 or 6. Past about 8 series the colors do not say which line is which, which is why hovering a row of the list highlights its line. The 25th series and the following ones take the colors again. A selected series keeps its color while it is selected. The colors are in `src/lib/palette.ts`, for the light and the dark theme. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. ## Address @@ -35,7 +47,7 @@ The address follows the explorer without adding history entries. What is wrong i A preset range ends now: it moves on every minute while the tab is on screen, and when the tab comes back. Dates that were typed do not move. -The list of series has a column for each label dimension, and the uuid in small. The Type column is there only when the series of a name are not all of one type: the server groups the metrics by name and type, so a name that exists as a float and as a string is two rows of the metrics list and one list of series. Choosing a metric with at most 8 series (the size of the palette) selects them all, the ones that can be drawn (numbers and booleans); with more, the choice is the user's. +The list of series has a column for each label dimension, sorted by the first one (numbers inside the values as numbers: `node-2` before `node-10`; a click on a header sorts by it, again reverses it; the server pages by creation order so the sort is of the page on screen), and the uuid in small. A label that every series has the same is said once above the list instead of a column: the InfluxDB endpoint adds `influxdb_org` and `influxdb_bucket` on purpose, they are part of the identity of a series. The selector box suggests a selector made of labels of the first series. The checkbox of a selected series has the color of its line, and the one of the header selects what is on screen. The Type column is there only when the series of a name are not all of one type: the server groups the metrics by name and type, so a name that exists as a float and as a string is two rows of the metrics list and one list of series. Choosing a metric with at most 8 series (the size of the palette) selects them all, the ones that can be drawn (numbers and booleans); with more, the choice is the user's. The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. diff --git a/current_tasks/frontend-review-round-4.md b/done/frontend-review-round-4.md similarity index 67% rename from current_tasks/frontend-review-round-4.md rename to done/frontend-review-round-4.md index 57d36f2d..105a87f6 100644 --- a/current_tasks/frontend-review-round-4.md +++ b/done/frontend-review-round-4.md @@ -40,8 +40,23 @@ Decision: **the time window of the store is the only window.** The chart draws e ## Steps -1. [ ] Header alignment -2. [ ] Time: store (history, pan, zoom out), the bar below the chart, brush zoom, progress bar -3. [ ] Series list: sort, placeholder, colored checkbox, shared labels -4. [ ] 24 colors, hover highlight -5. [ ] Docs, live check, done/ +1. [x] Header alignment (same padding as the cards) +2. [x] Time: store (history, pan, zoom out), the bar below the chart, brush zoom, progress bar +3. [x] Series list: sort, placeholder, colored checkbox, shared labels (also trimmed from the chart names) +4. [x] 24 colors, hover highlight, select all +5. [x] Docs, live check + +## Results + +Checked in a browser against a live SensApp: a drag on the chart sets the window (the address gets `from` and +`to`), the data is read again and finer, Back is enabled; 12 series each with a color of its own; hovering a row +makes its line stand out. `npm test` (171), lint, typecheck, build pass. + +The palette: the light one stays in the validated lightness band, the dark one goes outside it (up to L 0.84) for +the 16 extra colors: inside it there was no room (neighbours 4 apart). Numbers in `docs/FRONTEND.md`. The skill's +normal-vision floor (15 between neighbours) cannot hold past 8 colors, which is what the hover is for. + +Found on the way: echarts adds buttons for its brush to the toolbox whatever is said (`brush.toolbox: []` brings +the defaults back), so the toolbox is off (and so the save-as-image button with it); the header says "Drag on +the chart to zoom". `ideas/influxdb-labels-without-tags.md`: the importer adds org and bucket only to lines that +have tags. Not looked at: the logo alignment on a phone. diff --git a/ideas/frontend-constant-label-columns.md b/ideas/frontend-constant-label-columns.md deleted file mode 100644 index 00006c8e..00000000 --- a/ideas/frontend-constant-label-columns.md +++ /dev/null @@ -1,9 +0,0 @@ -# Frontend: do not spend a column on a label that never changes - -The series list has one column per label dimension. Data written with the InfluxDB endpoint carries -`influxdb_org` and `influxdb_bucket` on every series (the importer adds them), so two of the columns, and two -chips of every metric row, say the same thing on every row. - -Idea: when every series of the list has the same value for a dimension, say it once above the table -("same for all: influxdb_org=sensapp") instead of a column. Worth doing if people write through Influx -mostly; the other option is to ask whether the importer should add those labels at all. diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md index 9f0ae481..8b77bb5c 100644 --- a/ideas/frontend-next-steps.md +++ b/ideas/frontend-next-steps.md @@ -13,9 +13,13 @@ What was left out of `done/frontend-good-enough.md` on purpose, most useful firs series at a time, and the box would only add combining series (`avg by (room)`, through `/api/v1/query`). Revisit when someone asks for that. - Not planned: dashboards, alerting, a query builder (Grafana's job). -- **More than 8 series at once.** The colors of the series are eight validated hues and are used again past - eight (a series list of 9 selected has two blue ones). The dataviz guidance says to fold into "Other" or - facet; today the user is trusted not to select that many. A warning past 8, or a small multiples view, if it - turns out to matter. +- **An overview of the whole life of the series** in place of the echarts slider that was removed (the drag on the + chart and the bar below it do the zooming now): `/series/{uuid}/availability` could say where there is data, as + a thin strip above the time bar, clicking in it sets the window. A third thing to look at, so only if people + lose their way in long histories. +- **Wheel zoom and pan by drag** on the chart. The drag is a selection (as in Grafana) and the wheel scrolls the + page on small screens; `‹ ›` and `−` cover them. +- **More than 24 series** repeat colors. Past 8 the colors cannot say which is which anyway (see + `docs/FRONTEND.md`); small multiples, or a warning, would be the answer. - **Auto-refresh** was declined at first, but a preset range now follows the clock every minute (the end is now), which is the same thing for the chart. A refresh switch would only be needed to stop it. diff --git a/ideas/influxdb-labels-without-tags.md b/ideas/influxdb-labels-without-tags.md new file mode 100644 index 00000000..57be18cd --- /dev/null +++ b/ideas/influxdb-labels-without-tags.md @@ -0,0 +1,9 @@ +# InfluxDB importer: org and bucket are only added to lines that have tags + +`src/http/influxdb.rs` adds `influxdb_org` and `influxdb_bucket` to the labels of a line, on purpose: they are +part of the identity of a series (the same measurement written to two buckets is two series). But it does so only +`Some(tags)`: a line without tags (`power_w value=5`) gets no labels at all, so the same measurement without tags +written to two buckets or two orgs is one series. + +Probably an oversight rather than a decision. Either add the two labels to every line (a breaking change for +the ids of the series that already exist, which is fine before production), or write it down as intended. From 29e56eb39cec17dd3eb65ff8619e357850743b07 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 11:49:37 +0200 Subject: [PATCH 26/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20who?= =?UTF-8?q?le=20window=20width,=20quiet=20controls=20with=20one=20border?= =?UTF-8?q?=20in=20both=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page and the header were capped at 1280 px and the chart was 260 px high whatever the window: they follow the window now. The hint under the chart is gone. daisyUI's outline buttons and badges were drawn in the full text color (white frames on the dark theme): buttons and fields share a border that is a hint of the text color, the buttons that are on are tinted with the primary color, labels are washes without frames, and the scrollbars are thin and quiet. Co-Authored-By: Claude Sonnet 5.5 --- done/frontend-review-round-5.md | 18 +++++ frontend/src/components/ChartOptions.tsx | 2 +- frontend/src/components/Layout.tsx | 4 +- frontend/src/components/MetricsTable.tsx | 10 +-- frontend/src/components/SeriesTable.tsx | 10 +-- frontend/src/components/TimeRangeSelector.tsx | 10 +-- frontend/src/index.css | 71 +++++++++++++++++++ frontend/src/pages/ExplorerPage.tsx | 5 +- 8 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 done/frontend-review-round-5.md diff --git a/done/frontend-review-round-5.md b/done/frontend-review-round-5.md new file mode 100644 index 00000000..34c4aaa0 --- /dev/null +++ b/done/frontend-review-round-5.md @@ -0,0 +1,18 @@ +# Frontend: fifth review round (5 October 2026) + +Branch: `frontend-good-enough`. + +- **Width.** The header and the page were capped at 1280 px (`max-w-7xl`, from the first layout): a wide window had + margins on both sides. Both use the whole window now, and the chart is `clamp(260px, 40vh, 640px)` high instead + of a fixed 260 px, so a tall window gives it room. +- **"Drag on the chart to zoom"** removed: the crosshair cursor and the selection that follows the pointer say it, + and `docs/FRONTEND.md` does. +- **Controls, one look in both themes.** daisyUI draws outline buttons and badges in the full text color, which is + white frames on the dark theme. `index.css` has, outside the layers so that it wins: a border that is 18 % of the + text color (32 % on hover) for buttons and fields, `btn-quiet` (a wash on hover, tinted with the primary color + when it is on: the active preset, `log`), `chip` (a wash, no frame) for labels and dimensions, soft type badges, + a hover wash that is not darker than the card, and thin scrollbars that are not brighter than the page. +- Dimensions of the metrics list wrap in a wider cell (the name takes 40 %, not everything). + +Looked at in a browser, light and dark, at 1280 and 1600 px wide. Not looked at: the phone width, which these +changes do not touch. diff --git a/frontend/src/components/ChartOptions.tsx b/frontend/src/components/ChartOptions.tsx index 39b51746..aba06830 100644 --- a/frontend/src/components/ChartOptions.tsx +++ b/frontend/src/components/ChartOptions.tsx @@ -22,7 +22,7 @@ export function ChartOptions() { ))}
+
{name} {metric['sensor:unit'] && ( @@ -140,7 +140,7 @@ export function MetricsTable() {
- + {metric['sensor:type'].toLowerCase()} -
+
{dimensions.slice(0, 5).map((dim) => ( - + {dim} ))} diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index 59baa3c7..5c4531e8 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -165,7 +165,7 @@ export function SeriesTable() {
on every series {Object.entries(shared).map(([key, value]) => ( - + {key}= {value} @@ -226,7 +226,7 @@ export function SeriesTable() { className={`cursor-pointer transition-colors ${ selected ? 'bg-primary/8' - : 'hover:bg-base-200/60' + : 'hover:bg-base-content/5' }`} onClick={() => toggleSeries(toSeriesInfo(s))} onMouseEnter={() => setHoveredSeries(uuid)} @@ -261,7 +261,7 @@ export function SeriesTable() { )} {mixedTypes && (
- {s['sensor:type'].toLowerCase()} + {s['sensor:type'].toLowerCase()} 0 || next) && (
- -
- )} - )} - + ); } diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index b5a5f899..1047685b 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -39,7 +39,7 @@ export function TimeRangeSelector() { } return ( -
+
{/* Where the window is: back to the one before, earlier, wider, later */}
-
-
- {selectedMetric ? ( - - ) : ( -
-

Select a metric to browse series

-
- )} -
- + + {/* A new metric is a new list: nothing of the sort or the selector stays */} +
); From e4ef57177f19481f02ad9f4ca94eb60df58b4059 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:00:14 +0200 Subject: [PATCH 28/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20sam?= =?UTF-8?q?e=20space=20all=20around,=20one=20hover=20for=20the=20buttons,?= =?UTF-8?q?=20"Data=20Explorer"=20in=20Sora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page had twice the space at its sides than between the cards. The ghost buttons and the quiet ones had two hovers. The tagline of the header says Data Explorer, set in capitals in a display face. Co-Authored-By: Claude Sonnet 5.5 --- done/frontend-review-round-5.md | 10 ++++++++++ frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 1 + frontend/src/App.test.tsx | 2 +- frontend/src/components/AuthStatus.tsx | 2 +- frontend/src/components/Layout.tsx | 8 ++++---- frontend/src/index.css | 17 +++++++++++++++++ 7 files changed, 44 insertions(+), 6 deletions(-) diff --git a/done/frontend-review-round-5.md b/done/frontend-review-round-5.md index 6fa9d89c..e756f199 100644 --- a/done/frontend-review-round-5.md +++ b/done/frontend-review-round-5.md @@ -27,3 +27,13 @@ changes do not touch. "3 selected" went, `Clear (3)` says it. - A radio button on each metric. When the page opens on a metric (the address) the list scrolls to it, once; a click does not move the list. + +## Spacing, hover, title (same day) + +- The page had 24 px at the sides (`lg:px-6`) and 12 px between the cards and below: it is 12 px (`p-3`, `gap-3`) + all around now, the header included, so the logo lines up with the cards. +- Hover: the ghost buttons (API Docs, Clear, Sign out) had daisyUI's hover, the bar buttons their own. Both use + the same wash now (`--wash`); the quiet ones also darken their border. "Sign in" was an outline in the primary + color that filled on hover: it is a quiet button too. +- "Sensor Data Explorer" is "Data Explorer", in capitals with wide tracking, in Sora (variable, latin only, + 34 kB, `@fontsource-variable/sora`), the one place it is used (`font-display`). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 699f760d..7cf3e2a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@fontsource-variable/roboto": "^5.3.0", + "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", @@ -702,6 +703,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/sora": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz", + "integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hey-api/codegen-core": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bf884738..51480b70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@fontsource-variable/roboto": "^5.3.0", + "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 05f041b3..3f2c1a03 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -136,6 +136,6 @@ describe('App integration', () => { render(, { wrapper: createWrapper() }); expect(screen.getByAltText('SensApp')).toBeInTheDocument(); - expect(screen.getByText('Sensor Data Explorer')).toBeInTheDocument(); + expect(screen.getByText('Data Explorer')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx index 61da3ef4..ea9ad40e 100644 --- a/frontend/src/components/AuthStatus.tsx +++ b/frontend/src/components/AuthStatus.tsx @@ -31,7 +31,7 @@ export function AuthStatus() { if (authRequired) { return ( - ); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 73c4c298..4ef968eb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -13,7 +13,7 @@ export function Layout() { return (
-
+
@@ -25,8 +25,8 @@ export function Layout() { SensApp -
- Sensor Data Explorer +
+ Data Explorer
@@ -47,7 +47,7 @@ export function Layout() {
-
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 446932d4..27a8c73d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -9,6 +9,15 @@ src: url("@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2") format("woff2-variations"); } +/* The display face, for the few words of the header that are not the logo. */ +@font-face { + font-family: "Sora Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url("@fontsource-variable/sora/files/sora-latin-wght-normal.woff2") format("woff2-variations"); +} + @plugin "daisyui" { themes: false; logs: false; @@ -72,6 +81,7 @@ } @theme { + --font-display: "Sora Variable", "Roboto Variable", ui-sans-serif, system-ui, sans-serif; --font-sans: "Roboto Variable", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } @@ -122,6 +132,13 @@ html, body, #root { color: var(--color-primary); } +/* The ghost buttons (API Docs, Clear) get the wash of the quiet ones, nothing else */ +.btn.btn-ghost:hover:not(:disabled) { + background: var(--wash); + border-color: transparent; + box-shadow: none; +} + /* A label or a dimension: a wash, no frame */ .chip { display: inline-flex; From f53b50b0c220c968c42573b3f7a63a13041e67b7 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:18:57 +0200 Subject: [PATCH 29/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20code=20sn?= =?UTF-8?q?ippets=20from=20the=20state=20of=20the=20explorer=20(Python=20S?= =?UTF-8?q?DK,=20curl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generators behind the Code dialog, as plain functions with their tests. The code loads what the explorer shows: the selected series (uuid, named in a comment), the window (relative to now for a preset in Python), the step and the aggregation, the series a step cannot average read as they are. A token is only ever a placeholder (`SENSAPP_TOKEN`). Names and labels are quoted or kept on one line, so a label cannot add a line of code. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/lib/snippets.test.ts | 168 ++++++++++++++++++++++++ frontend/src/lib/snippets.ts | 204 ++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 frontend/src/lib/snippets.test.ts create mode 100644 frontend/src/lib/snippets.ts diff --git a/frontend/src/lib/snippets.test.ts b/frontend/src/lib/snippets.test.ts new file mode 100644 index 00000000..25250517 --- /dev/null +++ b/frontend/src/lib/snippets.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { curlSnippet, pythonSnippet } from './snippets'; +import type { SnippetInput } from './snippets'; + +const UUID_A = '11111111-1111-4111-8111-111111111111'; +const UUID_B = '22222222-2222-4222-8222-222222222222'; + +const base: SnippetInput = { + baseUrl: 'http://localhost:3000', + authenticated: false, + metric: 'cpu', + series: [{ uuid: UUID_A, name: 'cpu', labels: { host: 'a' }, type: 'float' }], + timeRange: { start: '2026-10-05T09:00:00.000Z', end: '2026-10-05T10:00:00.000Z' }, + relativeRange: null, + step: undefined, + aggregation: 'avg', +}; + +describe('pythonSnippet', () => { + it('reads a window of dates as they are, with the name of the series in a comment', () => { + const code = pythonSnippet(base); + expect(code).toContain('from sensapp import SensAppClient'); + expect(code).toContain(` "${UUID_A}", # cpu{host="a"}`); + expect(code).toContain('start = "2026-10-05T09:00:00Z"'); + expect(code).toContain('end = "2026-10-05T10:00:00Z"'); + expect(code).toContain('async with SensAppClient("http://localhost:3000") as client:'); + expect(code).toContain(' start=start,'); + expect(code).not.toContain('step='); + expect(code).not.toContain('datetime'); + expect(code).not.toContain('SENSAPP_TOKEN'); + }); + + it('writes a preset window relative to now', () => { + const code = pythonSnippet({ ...base, relativeRange: '24h' }); + expect(code).toContain('from datetime import UTC, datetime, timedelta'); + expect(code).toContain('end = datetime.now(UTC)'); + expect(code).toContain('start = end - timedelta(hours=24)'); + expect(code).toContain('start=start.isoformat(timespec="seconds"),'); + expect(code).not.toContain('2026-10-05'); + expect(pythonSnippet({ ...base, relativeRange: '7d' })).toContain('timedelta(days=7)'); + expect(pythonSnippet({ ...base, relativeRange: '15m' })).toContain('timedelta(minutes=15)'); + }); + + it('asks for the step and the aggregation of the chart', () => { + const code = pythonSnippet({ ...base, step: '5m', aggregation: 'max' }); + expect(code).toContain('step="5m",'); + expect(code).toContain('aggregation="max",'); + }); + + it('reads the token from the environment, and never has one', () => { + const code = pythonSnippet({ ...base, authenticated: true }); + expect(code).toContain('import os'); + expect(code).toContain('token=os.environ["SENSAPP_TOKEN"],'); + expect(code).toContain('sensapp generate-token'); + }); + + it('keeps the series a step cannot average apart, read as they are', () => { + const code = pythonSnippet({ + ...base, + step: '1m', + series: [ + base.series[0], + { uuid: UUID_B, name: 'door', labels: {}, type: 'boolean' }, + ], + }); + expect(code).toContain(`SERIES = [\n "${UUID_A}"`); + expect(code).toContain(`RAW_SERIES = [\n "${UUID_B}", # door\n]`); + expect(code.match(/step="1m"/g)).toHaveLength(1); + expect(code).toContain('for uuid in RAW_SERIES:'); + }); + + it('lists the series of the metric when none is selected, the metrics when there is no metric', () => { + const metric = pythonSnippet({ ...base, series: [] }); + expect(metric).toContain('client.list_series(metric="cpu")'); + expect(metric).not.toContain('get_series'); + const none = pythonSnippet({ ...base, series: [], metric: null }); + expect(none).toContain('client.list_metrics()'); + }); + + it('is not tricked by a label: quotes are escaped and a newline does not end the comment', () => { + const code = pythonSnippet({ + ...base, + baseUrl: 'http://host"; import os #', + series: [ + { uuid: UUID_A, name: 'cpu', labels: { host: 'a"\nimport os; os.system("x")\r\u2028b' }, type: 'float' }, + ], + }); + // Every line of the comment stays on its line + const line = code.split('\n').find((l) => l.includes(UUID_A)); + expect(line).toContain('import os; os.system('); + expect(line?.startsWith(' "')).toBe(true); + expect(code.split('\n').filter((l) => l.startsWith('import os'))).toHaveLength(0); + expect(code).toContain('"http://host\\"; import os #"'); + }); +}); + +describe('what the series are called', () => { + const two: SnippetInput = { + ...base, + series: [ + { uuid: UUID_A, name: 'cpu', labels: { host: 'a', org: 'o' }, type: 'float' }, + { uuid: UUID_B, name: 'cpu', labels: { host: 'b', org: 'o' }, type: 'float' }, + ], + }; + + it('says what every series has once, and what tells them apart on each', () => { + const python = pythonSnippet(two); + expect(python).toContain('# On every series: org="o"\nSERIES = ['); + expect(python).toContain(`"${UUID_A}", # cpu{host="a"}`); + const curl = curlSnippet(two); + expect(curl).toContain('# On every series: org="o"\n'); + expect(curl).toContain('# cpu{host="b"}\ncurl'); + }); + + it('says everything of a single series', () => { + const one = { ...two, series: [two.series[0]] }; + expect(pythonSnippet(one)).toContain('# cpu{host="a", org="o"}'); + expect(pythonSnippet(one)).not.toContain('On every series'); + }); +}); + +describe('curlSnippet', () => { + it('is one request for each series, with the window and the name in a comment', () => { + const code = curlSnippet({ + ...base, + series: [base.series[0], { uuid: UUID_B, name: 'mem', labels: {}, type: 'integer' }], + }); + expect(code.match(/^curl /gm)).toHaveLength(2); + expect(code).toContain('# cpu{host="a"}'); + expect(code).toContain( + `'http://localhost:3000/series/${UUID_A}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z'`, + ); + expect(code).toContain(`/series/${UUID_B}?`); + expect(code).not.toContain('Authorization'); + }); + + it('asks for the step and the aggregation, except for what cannot be averaged', () => { + const code = curlSnippet({ + ...base, + step: '5m', + aggregation: 'max', + series: [base.series[0], { uuid: UUID_B, name: 'door', labels: {}, type: 'boolean' }], + }); + expect(code).toContain(`/series/${UUID_A}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z&step=5m&aggregation=max'`); + expect(code).toContain(`/series/${UUID_B}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z'`); + }); + + it('sends the token of the environment', () => { + const code = curlSnippet({ ...base, authenticated: true }); + expect(code).toContain('-H "Authorization: Bearer $SENSAPP_TOKEN" \\'); + expect(code).toContain('# SENSAPP_TOKEN holds a token'); + }); + + it('lists the series of the metric, or the metrics', () => { + expect(curlSnippet({ ...base, series: [] })).toContain("'http://localhost:3000/series?metric=cpu'"); + expect(curlSnippet({ ...base, series: [], metric: null })).toContain("'http://localhost:3000/metrics'"); + }); + + it('is not tricked by a quote or a newline in what it quotes', () => { + const code = curlSnippet({ + ...base, + baseUrl: "http://host'; rm -rf ~; echo '", + series: [{ uuid: UUID_A, name: 'cpu', labels: { host: 'a\nrm -rf ~' }, type: 'float' }], + }); + expect(code).toContain(`'http://host'\\''; rm -rf ~; echo '\\''/series/`); + expect(code.split('\n').filter((l) => l.startsWith('rm '))).toHaveLength(0); + }); +}); diff --git a/frontend/src/lib/snippets.ts b/frontend/src/lib/snippets.ts new file mode 100644 index 00000000..affbc217 --- /dev/null +++ b/frontend/src/lib/snippets.ts @@ -0,0 +1,204 @@ +import { isNumericType } from './chartStep'; +import type { Aggregation } from './chartStep'; +import { sharedLabels, withoutShared } from './seriesLabels'; +import type { TimeRange } from './timeRange'; + +/** What a snippet says of the explorer: what is selected, and how it is read. */ +export interface SnippetInput { + /** The origin of the SensApp server */ + baseUrl: string; + /** The server asked for a token: the snippet reads it from `SENSAPP_TOKEN`, it never contains one */ + authenticated: boolean; + metric: string | null; + series: Array<{ uuid: string; name: string; labels: Record; type: string }>; + timeRange: TimeRange; + /** A preset such as `24h`: the window is written relative to now, as it is on screen */ + relativeRange: string | null; + /** The step of the chart, `undefined` for the raw samples */ + step: string | undefined; + aggregation: Aggregation; +} + +export const SNIPPET_LANGUAGES = ['python', 'curl'] as const; +export type SnippetLanguage = (typeof SNIPPET_LANGUAGES)[number]; + +export const INSTALL_COMMENT = + "uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'"; +export const TOKEN_COMMENT = 'sensapp generate-token me --scope read'; + +/** A line of text for a comment. A newline in a label must not turn the rest into code. */ +function oneLine(text: string): string { + return text.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, ' '); +} + +function pairs(labels: Record): string { + return Object.entries(labels) + .map(([key, value]) => `${key}="${value}"`) + .join(', '); +} + +/** + * What the series are called in the comments: the name and the labels that tell a series from the others, + * as in the list of the explorer. What every series has is said once, in `shared`. + */ +function names(series: SnippetInput['series']): { label: (s: SnippetInput['series'][number]) => string; shared: string | null } { + const common = sharedLabels(series.map((s) => s.labels)); + return { + label: (s) => { + const own = pairs(withoutShared(s.labels, common)); + return oneLine(own ? `${s.name}{${own}}` : s.name); + }, + shared: Object.keys(common).length > 0 ? oneLine(`On every series: ${pairs(common)}`) : null, + }; +} + +/** A Python string literal. A JSON string is one, with its escapes. */ +function py(value: string): string { + return JSON.stringify(value); +} + +/** A shell word that is one word whatever it contains. */ +function sh(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** The date the way a person writes it: `2026-10-05T09:00:00Z`, the milliseconds only when there are some. */ +function clean(iso: string): string { + return iso.replace(/\.000Z$/, 'Z'); +} + +/** `24h` as `hours=24`, the argument of a Python `timedelta`. */ +function timedelta(label: string): string | undefined { + const match = /^(\d+)([mhd])$/.exec(label); + if (!match) return undefined; + const unit = { m: 'minutes', h: 'hours', d: 'days' }[match[2] as 'm' | 'h' | 'd']; + return `${unit}=${match[1]}`; +} + +/** The series that the step can average, and the ones it cannot (booleans, strings): those are read as they are. */ +function groups(input: SnippetInput): Array<{ series: SnippetInput['series']; step?: string }> { + if (!input.step) return [{ series: input.series }]; + const aggregated = input.series.filter((s) => isNumericType(s.type)); + const raw = input.series.filter((s) => !isNumericType(s.type)); + return [ + ...(aggregated.length > 0 ? [{ series: aggregated, step: input.step }] : []), + ...(raw.length > 0 ? [{ series: raw }] : []), + ]; +} + +export function pythonSnippet(input: SnippetInput): string { + const delta = input.relativeRange ? timedelta(input.relativeRange) : undefined; + const lines: string[] = []; + + lines.push('import asyncio'); + if (input.authenticated) lines.push('import os'); + if (delta && input.series.length > 0) lines.push('from datetime import UTC, datetime, timedelta'); + lines.push('', 'from sensapp import SensAppClient', ''); + lines.push(`# ${INSTALL_COMMENT}`); + if (input.authenticated) lines.push(`# export SENSAPP_TOKEN=$(${TOKEN_COMMENT})`); + + const client = input.authenticated + ? [` async with SensAppClient(`, ` ${py(input.baseUrl)},`, ` token=os.environ["SENSAPP_TOKEN"],`, ` ) as client:`] + : [` async with SensAppClient(${py(input.baseUrl)}) as client:`]; + + if (input.series.length === 0) { + lines.push('', '', 'async def main() -> None:', ...client); + if (input.metric) { + lines.push( + ` catalog = await client.list_series(metric=${py(input.metric)})`, + ' for info in catalog.series:', + ' print(info.uuid, info.name, info.labels)', + ); + } else { + lines.push( + ' catalog = await client.list_metrics()', + ' for metric in catalog.metrics:', + ' print(metric.name, metric.sensor_type, metric.series_count)', + ); + } + lines.push('', '', 'asyncio.run(main())'); + return lines.join('\n') + '\n'; + } + + const parts = groups(input); + const mixed = parts.length > 1; + const variables = parts.map((part) => (mixed && !part.step ? 'RAW_SERIES' : 'SERIES')); + const { label, shared } = names(input.series); + + parts.forEach((part, index) => { + lines.push(''); + if (index === 0 && shared) lines.push(`# ${shared}`); + lines.push(`${variables[index]} = [`); + for (const s of part.series) lines.push(` ${py(s.uuid)}, # ${label(s)}`); + lines.push(']'); + }); + + lines.push('', '', 'async def main() -> None:'); + if (delta) { + lines.push(' end = datetime.now(UTC)', ` start = end - timedelta(${delta})`); + } else { + lines.push(` start = ${py(clean(input.timeRange.start))}`, ` end = ${py(clean(input.timeRange.end))}`); + } + lines.push(...client); + const [start, end] = delta ? ['start.isoformat(timespec="seconds")', 'end.isoformat(timespec="seconds")'] : ['start', 'end']; + parts.forEach((part, index) => { + lines.push( + ` for uuid in ${variables[index]}:`, + ' series = await client.get_series(', + ' uuid,', + ` start=${start},`, + ` end=${end},`, + ); + if (part.step) lines.push(` step=${py(part.step)},`, ` aggregation=${py(input.aggregation)},`); + lines.push( + ' )', + ' print(series.name, series.labels)', + ' print(series.frame) # a Polars DataFrame: timestamp, value', + ); + }); + lines.push('', '', 'asyncio.run(main())'); + return lines.join('\n') + '\n'; +} + +export function curlSnippet(input: SnippetInput): string { + const lines: string[] = []; + const { label, shared } = names(input.series); + if (input.authenticated) { + lines.push('# SENSAPP_TOKEN holds a token, made with:', `# ${TOKEN_COMMENT}`, ''); + } + if (shared) lines.push(`# ${shared}`, ''); + const auth = input.authenticated ? [' -H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; + const request = (comment: string | null, url: string) => [ + ...(comment ? [`# ${comment}`] : []), + 'curl -sS --fail-with-body \\', + ...auth.map((line) => `${line} \\`), + ` ${sh(url)}`, + ]; + + const blocks: string[][] = []; + if (input.series.length === 0) { + blocks.push( + input.metric + ? request(null, `${input.baseUrl}/series?${new URLSearchParams({ metric: input.metric })}`) + : request(null, `${input.baseUrl}/metrics`), + ); + } else { + for (const part of groups(input)) { + for (const s of part.series) { + const params = new URLSearchParams({ format: 'csv', start: clean(input.timeRange.start), end: clean(input.timeRange.end) }); + if (part.step) { + params.set('step', part.step); + params.set('aggregation', input.aggregation); + } + // The colons of the dates are fine in a query: leave them readable + const query = params.toString().replace(/%3A/g, ':'); + blocks.push(request(label(s), `${input.baseUrl}/series/${encodeURIComponent(s.uuid)}?${query}`)); + } + } + } + return lines.concat(blocks.map((block) => block.join('\n')).join('\n\n')).join('\n') + '\n'; +} + +export function snippetFor(language: SnippetLanguage, input: SnippetInput): string { + return language === 'python' ? pythonSnippet(input) : curlSnippet(input); +} From 14c4462b8c8eda71ff76264175e98c9126607168 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:18:57 +0200 Subject: [PATCH 30/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20a=20Code?= =?UTF-8?q?=20button,=20with=20the=20Python=20SDK=20and=20curl,=20in=20Jet?= =?UTF-8?q?Brains=20Mono=20on=20a=20dark=20ground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header has a Code button before API Docs. It opens a dialog with two tabs, Python and curl, coloured by highlight.js (core, Python and Bash only), in JetBrains Mono, dark in both themes, with a copy button that also works on a page served by plain http. The dialog, the highlighter and the font load when it is first opened (13.6 kB gzipped, nothing for a page that never opens it). Checked live: the snippets of the dialog were run against a SensApp, with and without JWT. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 12 ++ done/frontend-code-snippets.md | 44 +++++++ frontend/package-lock.json | 20 ++++ frontend/package.json | 2 + frontend/src/components/CodeButton.test.tsx | 119 +++++++++++++++++++ frontend/src/components/CodeButton.tsx | 29 +++++ frontend/src/components/CodeDialog.tsx | 124 ++++++++++++++++++++ frontend/src/components/Layout.tsx | 2 + frontend/src/index.css | 47 ++++++++ frontend/src/lib/copyText.ts | 28 +++++ ideas/frontend-next-steps.md | 4 + 11 files changed, 431 insertions(+) create mode 100644 done/frontend-code-snippets.md create mode 100644 frontend/src/components/CodeButton.test.tsx create mode 100644 frontend/src/components/CodeButton.tsx create mode 100644 frontend/src/components/CodeDialog.tsx create mode 100644 frontend/src/lib/copyText.ts diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 50dbd81b..2012e551 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -30,6 +30,18 @@ There is one time window, the one of the explorer, and the chart draws exactly t The first 8 series have the eight hues of the dataviz palette, validated for color-blind readers (neighbours are 8.4 or more apart in OKLab ×100 under protan and deutan vision, 19 or more with normal vision). The 16 colors after them are there so that no series repeats a color: each was chosen as the farthest, under normal and color-blind vision, from the ones before it. They are not equally good: the 9th to 11th are 10 or more from the others, from the 12th it goes down to 5 or 6. Past about 8 series the colors do not say which line is which, which is why hovering a row of the list highlights its line. The 25th series and the following ones take the colors again. A selected series keeps its color while it is selected. The colors are in `src/lib/palette.ts`, for the light and the dark theme. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. +## Code + +The **Code** button of the header opens the code that loads what the explorer shows, in two tabs: the [Python SDK](../python/sensapp/README.md) (`get_series`, which gives a Polars DataFrame) and `curl` (one request for each series, as CSV). A button copies it. + +- It is made from the state of the explorer: the selected series (their uuid, named in a comment: what every series has is said once, as in the list), the time window, the step and the aggregation. A series the step cannot average (booleans, strings) is read as it is, as in the chart. +- A preset window is **relative to now** in Python (`datetime.now(UTC) - timedelta(hours=24)`), so the script can be run again later; a window of dates, and every curl request, have the dates written out. +- The server is the origin of the page. When the server asked for a token, or one is in use, the code reads it from `SENSAPP_TOKEN` (`os.environ["SENSAPP_TOKEN"]`, `$SENSAPP_TOKEN`) and says how to make one. **The token of the session is never written in the code**: a snippet is meant to be pasted, saved and shared. +- With no series selected it lists the series of the metric, and with no metric the metrics. +- Whatever a label or a name says is quoted (Python and shell) or put on one line (comments): a hostile label cannot add a line of code to what is copied. +- The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. +- The Python SDK is not on PyPI yet: the snippet installs it from GitHub, `uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'`. Change `INSTALL_COMMENT` in `src/lib/snippets.ts` when it is published. + ## Address The explorer is in the address, so a link shares a view and a reload keeps it: `/ui/?metric=cpu&series=&series=&range=24h&step=5m&agg=max&style=area&log=1`. diff --git a/done/frontend-code-snippets.md b/done/frontend-code-snippets.md new file mode 100644 index 00000000..039a20a2 --- /dev/null +++ b/done/frontend-code-snippets.md @@ -0,0 +1,44 @@ +# Frontend: code snippets modal + +Branch: `frontend-good-enough`. + +## Goal + +A button in the header (next to API Docs) opens a modal with a snippet that loads what the explorer shows: +the Python SDK (`sensapp`) in one tab, curl in the other. Simple, but well done. + +## Decisions + +- The snippet is generated from the state of the explorer: the selected series (uuid, with the name and labels in + a comment), the time window, the step, the aggregation. No series selected: the metric's series, or the metrics. +- A preset window (live) is written relative to now in Python (`datetime.now(UTC) - timedelta(...)`); in curl the + dates are absolute. A window of dates is absolute in both. +- The server is the origin of the page. Authentication: when the server asked for a token (or one is in use) the + snippet reads it from `SENSAPP_TOKEN`. The real token is never written in a snippet. +- Dark code block in both themes. JetBrains Mono (`@fontsource-variable`, latin, same as Roboto and Sora), + highlight.js core with Python and Bash only, loaded with the modal (not in the main bundle). A copy button. + +## Steps + +1. [x] Snippet generators (`src/lib/snippets.ts`) with tests +2. [x] Modal, highlighting, font, copy button +3. [x] Header button, tests, live check in a browser, docs (`docs/FRONTEND.md`) + +## Results + +- `src/lib/snippets.ts` (generators, 14 tests), `CodeDialog.tsx` and `CodeButton.tsx` (9 tests), `lib/copyText.ts`. + Full suite: 198 tests, lint, typecheck and build clean. +- Checked against a live SensApp (SQLite, seeded through the InfluxDB endpoint): the Python snippet and the curl + snippet were copied out of the dialog and run, they return the data of the chart (72 averaged points of 5 minutes + for 6 hours, and the same in CSV). With JWT enabled: the 401 makes the snippet use `SENSAPP_TOKEN`, and the same + snippet runs with a token in the environment and fails with the server's 401 without one. +- Every state (preset, dates, mixed types, auth, metric only, nothing, hostile labels) was generated and parsed by + Python's `ast` and `bash -n`; the ones with real uuids pass `ruff check` and `ruff format --check` of the SDK. +- Looked at in a browser, dark and light, desktop and a 375 px phone. +- The dialog chunk is 33.9 kB (13.6 kB gzipped), loaded on first use, and the font file (40 kB) is fetched only then. + +## Not done + +- The copy button was exercised by tests (the clipboard API and the `execCommand` fallback), not by a click in a + real browser: the pane does not grant the clipboard. +- Not checked in Safari or Firefox. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7cf3e2a6..e0f59a18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,13 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", + "highlight.js": "^11.12.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -694,6 +696,15 @@ } } }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/roboto": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz", @@ -3042,6 +3053,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 51480b70..4f431976 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,13 @@ "openapi-ts": "openapi-ts" }, "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", + "highlight.js": "^11.12.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", diff --git a/frontend/src/components/CodeButton.test.tsx b/frontend/src/components/CodeButton.test.tsx new file mode 100644 index 00000000..27dca6c6 --- /dev/null +++ b/frontend/src/components/CodeButton.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import { fakeToken } from '../test/fakeToken'; +import { CodeButton } from './CodeButton'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +beforeEach(() => { + useAuthStore.setState({ token: null, authRequired: false }); + useSelectionStore.setState({ + selectedMetric: 'cpu', + selectedSeries: [{ uuid: UUID, name: 'cpu', labels: { host: 'a' }, type: 'float', slot: 0 }], + timeRange: { start: '2026-10-05T09:00:00.000Z', end: '2026-10-05T10:00:00.000Z' }, + relativeRange: null, + step: 'raw', + aggregation: 'avg', + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +async function openDialog() { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Code' })); + // The dialog is loaded when it is first opened + await screen.findByRole('dialog', { name: 'Code' }); + return user; +} + +const code = () => screen.getByRole('tabpanel').textContent ?? ''; + +describe('CodeButton', () => { + it('shows the Python code of the selection, then curl', async () => { + const user = await openDialog(); + expect(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain('from sensapp import SensAppClient'); + expect(code()).toContain(UUID); + + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain(`curl -sS`); + expect(code()).toContain(`/series/${UUID}?format=csv`); + }); + + it('colors the code', async () => { + await openDialog(); + expect(screen.getByRole('tabpanel').querySelectorAll('span.hljs-keyword').length).toBeGreaterThan(0); + expect(screen.getByRole('tabpanel').querySelector('span.hljs-string')).not.toBeNull(); + }); + + it('goes from a tab to the other with the arrow keys, and closes with Escape', async () => { + const user = await openDialog(); + screen.getByRole('tab', { name: 'Python' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveFocus(); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveAttribute('aria-selected', 'true'); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('copies the code that is shown', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('tab', { name: 'curl' })); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(await navigator.clipboard.readText()).toBe(code()); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('copies on a page where the clipboard API does not exist (plain http)', async () => { + const user = await openDialog(); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('says so when it cannot copy', async () => { + const user = await openDialog(); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + Object.defineProperty(document, 'execCommand', { value: vi.fn().mockReturnValue(false), configurable: true }); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(screen.getByRole('button', { name: 'Copy failed' })).toBeInTheDocument(); + }); + + it('reads the token from the environment once there is authentication, and never shows it', async () => { + const token = fakeToken({ sub: 'alice', scope: 'read' }); + useAuthStore.setState({ token }); + const user = await openDialog(); + expect(code()).toContain('os.environ["SENSAPP_TOKEN"]'); + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(code()).toContain('Authorization: Bearer $SENSAPP_TOKEN'); + expect(document.body.innerHTML).not.toContain(token); + }); + + it('has no token in the code of a server that asks for none', async () => { + await openDialog(); + expect(code()).not.toContain('SENSAPP_TOKEN'); + }); + + it('follows what is selected: the series of the metric, or the metrics', async () => { + useSelectionStore.setState({ selectedSeries: [] }); + const first = await openDialog(); + expect(code()).toContain('client.list_series(metric="cpu")'); + expect(screen.getByText(/No series is selected/)).toBeInTheDocument(); + await first.keyboard('{Escape}'); + + useSelectionStore.setState({ selectedMetric: null }); + const second = userEvent.setup(); + await second.click(screen.getByRole('button', { name: 'Code' })); + await screen.findByRole('dialog', { name: 'Code' }); + expect(code()).toContain('client.list_metrics()'); + }); +}); diff --git a/frontend/src/components/CodeButton.tsx b/frontend/src/components/CodeButton.tsx new file mode 100644 index 00000000..19174d8a --- /dev/null +++ b/frontend/src/components/CodeButton.tsx @@ -0,0 +1,29 @@ +import { lazy, Suspense, useState } from 'react'; + +// highlight.js and the snippets are loaded when the dialog is first opened +const CodeDialog = lazy(() => import('./CodeDialog')); + +/** Opens the code that loads what the explorer shows. */ +export function CodeButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( + + setOpen(false)} /> + + )} + + ); +} diff --git a/frontend/src/components/CodeDialog.tsx b/frontend/src/components/CodeDialog.tsx new file mode 100644 index 00000000..0aecd641 --- /dev/null +++ b/frontend/src/components/CodeDialog.tsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import python from 'highlight.js/lib/languages/python'; +import { resolveStep } from '../lib/chartStep'; +import { copyText } from '../lib/copyText'; +import { SNIPPET_LANGUAGES, snippetFor } from '../lib/snippets'; +import type { SnippetLanguage } from '../lib/snippets'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +hljs.registerLanguage('python', python); +hljs.registerLanguage('bash', bash); + +const LABELS: Record = { python: 'Python', curl: 'curl' }; +const HIGHLIGHT_AS: Record = { python: 'python', curl: 'bash' }; + +/** Code that loads what the explorer shows: the Python SDK, or curl. */ +export default function CodeDialog({ onClose }: { onClose: () => void }) { + const [language, setLanguage] = useState('python'); + const [copied, setCopied] = useState(null); + const { selectedMetric, selectedSeries, timeRange, relativeRange, step, aggregation } = useSelectionStore(); + const { token, authRequired } = useAuthStore(); + + const code = useMemo( + () => + snippetFor(language, { + baseUrl: import.meta.env.VITE_SENSAPP_API_URL || window.location.origin, + authenticated: token !== null || authRequired, + metric: selectedMetric, + series: selectedSeries, + timeRange, + relativeRange, + step: resolveStep(step, timeRange.start, timeRange.end), + aggregation, + }), + [language, selectedMetric, selectedSeries, timeRange, relativeRange, step, aggregation, token, authRequired], + ); + // highlight.js escapes the text it is given: what it returns is safe to put in the page + const html = useMemo(() => hljs.highlight(code, { language: HIGHLIGHT_AS[language] }).value, [code, language]); + + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); + + async function handleCopy() { + setCopied(await copyText(code)); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(null), 2000); + } + + // Left and right go from a tab to the other, as in any tab list + function handleTabKey(event: KeyboardEvent) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const next = SNIPPET_LANGUAGES[(SNIPPET_LANGUAGES.indexOf(language) + 1) % SNIPPET_LANGUAGES.length]; + setLanguage(next); + document.getElementById(`code-tab-${next}`)?.focus(); + } + + const nothingSelected = selectedSeries.length === 0; + + return ( +
event.key === 'Escape' && onClose()} + > +
+
+

+ Code +

+ +
+ +
+
+ {SNIPPET_LANGUAGES.map((id) => ( + + ))} +
+ + + {copied === true ? 'Copied to the clipboard' : copied === false ? 'Could not copy' : ''} + +
+ +
+
+            
+          
+ {nothingSelected && ( +

+ {selectedMetric + ? 'No series is selected: this lists the series of the metric. Select some to get their data.' + : 'Nothing is selected: this lists the metrics. Select series to get their data.'} +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4ef968eb..2453414f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { Link, Outlet } from 'react-router-dom'; import { AuthDialog } from './AuthDialog'; import { AuthStatus } from './AuthStatus'; +import { CodeButton } from './CodeButton'; // The files of public/, served next to the page (`BASE_URL` is `/ui/` when SensApp serves them) const base = import.meta.env.BASE_URL; @@ -31,6 +32,7 @@ export function Layout() {
+ { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // refused (permissions, an iframe): try the other way + } + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + area.remove(); + } +} diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md index 8b77bb5c..dd39c6f5 100644 --- a/ideas/frontend-next-steps.md +++ b/ideas/frontend-next-steps.md @@ -23,3 +23,7 @@ What was left out of `done/frontend-good-enough.md` on purpose, most useful firs `docs/FRONTEND.md`); small multiples, or a warning, would be the answer. - **Auto-refresh** was declined at first, but a preset range now follows the clock every minute (the end is now), which is the same thing for the chart. A refresh switch would only be needed to stop it. +- **More in the Code dialog.** Only reading is covered (Python, curl). Left out on purpose: writing (`publish`), other + languages (JavaScript, R), a PromQL query (`client.query('cpu{host="a"}[24h]')`, which would also work for a + window that follows the clock), and a Polars/pandas plot at the end of the Python code. A format choice for curl + (SenML and JSON Lines besides CSV) if someone needs it. From 698931d7f435c756054c4c1568fa8a7f53fcdc70 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:27:23 +0200 Subject: [PATCH 31/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20code=20sn?= =?UTF-8?q?ippets=20with=20a=20PEP=20723=20header=20for=20uv,=20and=20the?= =?UTF-8?q?=20selector=20of=20the=20series=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python snippet starts with an inline script block (`# /// script`) so that `uv run script.py` installs the SDK from GitHub by itself; the `uv pip install` command stays next to it. The selector box of the series list moves to the selection store. When one is typed, the code asks the server for the series that match it (`list_series(metric=, selector=)` in Python, `curl -G --data-urlencode` into `jq` and a loop in the shell) instead of listing uuids, and a checkbox of the dialog goes back to the checked series. A step is only asked for the numbers among series whose types are not known when the code is written. Checked live: `uv run` with only the block, selector snippets against a SensApp (mixed types, with a step), and a hostile selector, metric and address in a real shell with curl stubbed: no command ran. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 5 +- done/frontend-code-snippets-selector.md | 39 ++++ frontend/src/components/CodeButton.test.tsx | 20 ++ frontend/src/components/CodeDialog.tsx | 28 ++- frontend/src/components/SeriesTable.tsx | 5 +- frontend/src/lib/snippets.test.ts | 78 ++++++++ frontend/src/lib/snippets.ts | 178 +++++++++++++----- frontend/src/stores/useSelectionStore.test.ts | 9 + frontend/src/stores/useSelectionStore.ts | 8 +- 9 files changed, 316 insertions(+), 54 deletions(-) create mode 100644 done/frontend-code-snippets-selector.md diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 2012e551..43fca492 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -37,10 +37,11 @@ The **Code** button of the header opens the code that loads what the explorer sh - It is made from the state of the explorer: the selected series (their uuid, named in a comment: what every series has is said once, as in the list), the time window, the step and the aggregation. A series the step cannot average (booleans, strings) is read as it is, as in the chart. - A preset window is **relative to now** in Python (`datetime.now(UTC) - timedelta(hours=24)`), so the script can be run again later; a window of dates, and every curl request, have the dates written out. - The server is the origin of the page. When the server asked for a token, or one is in use, the code reads it from `SENSAPP_TOKEN` (`os.environ["SENSAPP_TOKEN"]`, `$SENSAPP_TOKEN`) and says how to make one. **The token of the session is never written in the code**: a snippet is meant to be pasted, saved and shared. -- With no series selected it lists the series of the metric, and with no metric the metrics. +- **A selector** typed in the box of the series list (`{host="a"}`, with the metric) is what the code asks for, instead of the uuids: `list_series(metric=…, selector=…)` then `get_series` of each in Python, and `curl -G --data-urlencode` into `jq` and a loop in the shell (`jq` is needed). The code then reads every series that matches, whenever it runs, which is not always what is checked: the checkbox above the code goes back to the selected series. The selector is forgotten when another metric is chosen. As the series are not known when the code is written, a step is only asked for the numbers among them (Python looks at `sensor_type`, which the server writes `Float`; the shell loop leaves the others out and says so). A selector that matches more than a page (256 series) reads the first page. +- With no series selected, and no selector, it lists the series of the metric, and with no metric the metrics. - Whatever a label or a name says is quoted (Python and shell) or put on one line (comments): a hostile label cannot add a line of code to what is copied. - The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. -- The Python SDK is not on PyPI yet: the snippet installs it from GitHub, `uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'`. Change `INSTALL_COMMENT` in `src/lib/snippets.ts` when it is published. +- The Python SDK is not on PyPI yet. The script starts with a [PEP 723](https://peps.python.org/pep-0723/) block (`# /// script`) that says it needs `sensapp` and that it comes from GitHub (`[tool.uv.sources.sensapp]`), so `uv run script.py` is enough: uv makes the environment. The `uv pip install` command is there too, for an environment of one's own. Both are in `src/lib/snippets.ts` (`SCRIPT_METADATA`, `INSTALL_COMMENT`): change them when the SDK is published, and the Python the block asks for follows `requires-python` of the SDK. ## Address diff --git a/done/frontend-code-snippets-selector.md b/done/frontend-code-snippets-selector.md new file mode 100644 index 00000000..5e054c37 --- /dev/null +++ b/done/frontend-code-snippets-selector.md @@ -0,0 +1,39 @@ +# Frontend: code snippets, PEP 723 header and selectors + +Follow-up of `done/frontend-code-snippets.md`, same branch. + +- The Python snippet gets a PEP 723 block (`# /// script`) so `uv run script.py` installs the SDK from GitHub, next + to the `uv pip install` comment. +- The selector box of the series list (`{host="a"}`) moves from the local state of `SeriesTable` to the store, so the + dialog sees it. When it is set, the code asks the server for the series that match it (`list_series(metric=, + selector=)`; `curl -G --data-urlencode` and `jq` in the shell) instead of listing uuids, and a checkbox of the + dialog goes back to the selected series: the user may have unchecked some of what matches. +- A step only averages numbers. Series that match a selector are not known when the code is written, so the code + checks the type of each (`sensor_type` is `Float` on the wire, not `float`: lower-case it). + +## Steps + +1. [x] Selector in the store +2. [x] Generators: PEP 723, selector mode, tests +3. [x] Dialog checkbox, live check (uv run with the block, selector snippets against a server), docs + +## Results + +- `uv run` with only the PEP 723 block, from a directory outside the project, installed the SDK from GitHub `main` + and ran the script. ruff (88 columns) objected to the one-line `[tool.uv.sources]` table: it is the multi-line + `[tool.uv.sources.sensapp]` form now. +- Selector snippets run against a live SensApp: Python and curl return the same series as the chart for + `{host="a"}`; with a step on a selector that matches 4 series of 3 types (float, boolean, string) the floats come + back aggregated (12 points of 30 minutes) and the others raw (349), as the guard intends; the curl loop leaves the + non-numeric ones out. +- Found by reading the output: `\$uuid` (the escape of the page's text also escaped the variable) and a loop body + indented one level too far. Fixed, and tested. +- A hostile selector, metric and address (quotes, `$(…)`, backticks, newlines) were run in a real shell with `curl` + and `jq` stubbed: no command ran, and the loop got the literal address. +- 210 tests, lint, typecheck and build clean. Checkbox looked at in the browser. + +## Not done + +- curl and Python differ for a step on a selector: Python reads the non-numeric series raw, the shell loop leaves + them out (a per-series choice in `jq` would read worse than the rule it replaces). +- Pages: a selector that matches more than 256 series reads the first page only. diff --git a/frontend/src/components/CodeButton.test.tsx b/frontend/src/components/CodeButton.test.tsx index 27dca6c6..396f7656 100644 --- a/frontend/src/components/CodeButton.test.tsx +++ b/frontend/src/components/CodeButton.test.tsx @@ -12,6 +12,7 @@ beforeEach(() => { useAuthStore.setState({ token: null, authRequired: false }); useSelectionStore.setState({ selectedMetric: 'cpu', + selector: '', selectedSeries: [{ uuid: UUID, name: 'cpu', labels: { host: 'a' }, type: 'float', slot: 0 }], timeRange: { start: '2026-10-05T09:00:00.000Z', end: '2026-10-05T10:00:00.000Z' }, relativeRange: null, @@ -116,4 +117,23 @@ describe('CodeButton', () => { await screen.findByRole('dialog', { name: 'Code' }); expect(code()).toContain('client.list_metrics()'); }); + + it('asks for what the selector of the series matches, unless the user prefers the series that are checked', async () => { + useSelectionStore.setState({ selector: '{host="a"}' }); + const user = await openDialog(); + expect(code()).toContain(`selector='{host="a"}'`); + expect(code()).not.toContain(UUID); + const box = screen.getByRole('checkbox', { name: /Read every series matching/ }); + expect(box).toBeChecked(); + expect(screen.getByText(/instead of the 1 selected/)).toBeInTheDocument(); + + await user.click(box); + expect(code()).toContain(UUID); + expect(code()).not.toContain('selector='); + }); + + it('has no checkbox when no selector is typed', async () => { + await openDialog(); + expect(screen.queryByRole('checkbox')).toBeNull(); + }); }); diff --git a/frontend/src/components/CodeDialog.tsx b/frontend/src/components/CodeDialog.tsx index 0aecd641..a13259e6 100644 --- a/frontend/src/components/CodeDialog.tsx +++ b/frontend/src/components/CodeDialog.tsx @@ -20,7 +20,11 @@ const HIGHLIGHT_AS: Record = { python: 'python', curl: export default function CodeDialog({ onClose }: { onClose: () => void }) { const [language, setLanguage] = useState('python'); const [copied, setCopied] = useState(null); - const { selectedMetric, selectedSeries, timeRange, relativeRange, step, aggregation } = useSelectionStore(); + const { selectedMetric, selectedSeries, selector, timeRange, relativeRange, step, aggregation } = useSelectionStore(); + // A selector that is typed in the series list is what the code asks for, unless the user prefers the series that are checked + const [bySelector, setBySelector] = useState(true); + const typedSelector = selector.trim(); + const useSelector = typedSelector !== '' && bySelector; const { token, authRequired } = useAuthStore(); const code = useMemo( @@ -29,13 +33,14 @@ export default function CodeDialog({ onClose }: { onClose: () => void }) { baseUrl: import.meta.env.VITE_SENSAPP_API_URL || window.location.origin, authenticated: token !== null || authRequired, metric: selectedMetric, + selector: useSelector ? typedSelector : undefined, series: selectedSeries, timeRange, relativeRange, step: resolveStep(step, timeRange.start, timeRange.end), aggregation, }), - [language, selectedMetric, selectedSeries, timeRange, relativeRange, step, aggregation, token, authRequired], + [language, selectedMetric, selectedSeries, useSelector, typedSelector, timeRange, relativeRange, step, aggregation, token, authRequired], ); // highlight.js escapes the text it is given: what it returns is safe to put in the page const html = useMemo(() => hljs.highlight(code, { language: HIGHLIGHT_AS[language] }).value, [code, language]); @@ -106,10 +111,27 @@ export default function CodeDialog({ onClose }: { onClose: () => void }) {
+ {typedSelector !== '' && ( + + )}
             
           
- {nothingSelected && ( + {nothingSelected && !useSelector && (

{selectedMetric ? 'No series is selected: this lists the series of the metric. Select some to get their data.' diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index 26319b6e..384b0eb8 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -44,9 +44,10 @@ export function SeriesTable() { setHoveredSeries, labelFilter, setLabelFilter, + selector: selectorInput, + setSelector, } = useSelectionStore(); const dark = usePrefersDark(); - const [selectorInput, setSelectorInput] = useState(''); // The cursor of every page shown so far: the server only goes forward, so Previous pops one const [bookmarks, setBookmarks] = useState([]); // Sorted by the first column, until another one is clicked, and again to reverse it @@ -137,7 +138,7 @@ export function SeriesTable() { className="input input-xs font-mono text-xs flex-1 min-w-32 max-w-56 h-7" value={selectorInput} onChange={(e) => { - setSelectorInput(e.target.value); + setSelector(e.target.value); setBookmarks([]); }} /> diff --git a/frontend/src/lib/snippets.test.ts b/frontend/src/lib/snippets.test.ts index 25250517..f276d8de 100644 --- a/frontend/src/lib/snippets.test.ts +++ b/frontend/src/lib/snippets.test.ts @@ -94,6 +94,84 @@ describe('pythonSnippet', () => { }); }); +describe('the script header', () => { + it('lets `uv run` install the SDK (PEP 723), and keeps the install command for the others', () => { + const code = pythonSnippet(base); + expect(code.startsWith('# /// script\n# requires-python = ">=3.14"\n# dependencies = ["sensapp"]\n')).toBe(true); + expect(code).toContain( + '# [tool.uv.sources.sensapp]\n# git = "https://github.com/SINTEF/sensapp.git"\n# subdirectory = "python/sensapp"\n# branch = "main"\n# ///', + ); + expect(code).toContain('# ///\n'); + expect(code).toContain("# uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'"); + // The block closes before the code starts + expect(code.indexOf('# ///\n', 5)).toBeLessThan(code.indexOf('import asyncio')); + }); +}); + +describe('a selector', () => { + const selected: SnippetInput = { ...base, selector: '{host="a"}', metric: 'cpu value' }; + + it('asks the server for the series that match, in Python', () => { + const code = pythonSnippet(selected); + expect(code).toContain('catalog = await client.list_series(\n metric="cpu value",\n selector=\'{host="a"}\',\n )'); + expect(code).toContain('for info in catalog.series:'); + expect(code).toContain('info.uuid,'); + expect(code).not.toContain(UUID_A); + expect(code).not.toContain('SERIES'); + // No step: nothing to say about types + expect(code).not.toContain('numeric'); + }); + + it('reads a step only for the numbers among the series, whose type is not known yet', () => { + const code = pythonSnippet({ ...selected, step: '5m', aggregation: 'max' }); + expect(code).toContain('numeric = info.sensor_type.lower() in ("integer", "float", "numeric")'); + expect(code).toContain('step="5m" if numeric else None,'); + expect(code).toContain('aggregation="max" if numeric else None,'); + }); + + it('keeps the window of the explorer', () => { + expect(pythonSnippet({ ...selected, relativeRange: '24h' })).toContain('start = end - timedelta(hours=24)'); + expect(pythonSnippet(selected)).toContain('start = "2026-10-05T09:00:00Z"'); + }); + + it('quotes the selector whatever it holds', () => { + const both = pythonSnippet({ ...selected, selector: `{host=~"a.*", note="it's"}` }); + expect(both).toContain('selector="{host=~\\"a.*\\", note=\\"it\'s\\"}",'); + const ugly = pythonSnippet({ ...selected, selector: '{a="\n1"}' }); + expect(ugly.split('\n').filter((l) => l.startsWith('1"'))).toHaveLength(0); + }); + + it('lists with curl, then reads each series with jq and a loop', () => { + const code = curlSnippet({ ...selected, authenticated: true }); + expect(code).toContain(" --data-urlencode 'metric=cpu value' \\\n --data-urlencode 'selector={host=\"a\"}' \\\n 'http://localhost:3000/series' |"); + expect(code).toContain(`jq -r '.["dcat:dataset"][]["dct:identifier"]' |`); + expect(code).toContain('while read -r uuid; do'); + // $uuid is the one thing the shell has to read + expect(code).toContain('"http://localhost:3000/series/$uuid?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z"'); + expect(code.match(/Authorization: Bearer \$SENSAPP_TOKEN/g)).toHaveLength(2); + expect(code).not.toContain(UUID_A); + }); + + it('leaves the series a step cannot average out of the curl loop, and says so', () => { + const code = curlSnippet({ ...selected, step: '5m', aggregation: 'max' }); + expect(code).toContain('select(.["sensor:type"] | ascii_downcase | IN("integer", "float", "numeric"))'); + expect(code).toContain('step=5m&aggregation=max"'); + expect(code).toContain('the numeric ones'); + }); + + it('is not tricked by the selector, or by the address, in the shell', () => { + const code = curlSnippet({ ...selected, baseUrl: 'http://h"$(rm -rf ~)`x', selector: `{a="it's"}\nrm -rf ~` }); + expect(code).toContain(`--data-urlencode 'selector={a="it'\\''s"}\nrm -rf ~'`); + expect(code).toContain('"http://h\\"\\$(rm -rf ~)\\`x/series/$uuid?'); + // The comment stays on its line: a newline in the selector is only ever inside quotes + expect(code).toContain(`# The series that match {a="it's"} rm -rf ~\n`); + }); + + it('is ignored when it is blank', () => { + expect(pythonSnippet({ ...selected, selector: ' ' })).toContain(UUID_A); + }); +}); + describe('what the series are called', () => { const two: SnippetInput = { ...base, diff --git a/frontend/src/lib/snippets.ts b/frontend/src/lib/snippets.ts index affbc217..31f4eaa1 100644 --- a/frontend/src/lib/snippets.ts +++ b/frontend/src/lib/snippets.ts @@ -10,6 +10,8 @@ export interface SnippetInput { /** The server asked for a token: the snippet reads it from `SENSAPP_TOKEN`, it never contains one */ authenticated: boolean; metric: string | null; + /** The selector of the series list (`{host="a"}`). Set, the code asks the server for the series that match it. */ + selector?: string; series: Array<{ uuid: string; name: string; labels: Record; type: string }>; timeRange: TimeRange; /** A preset such as `24h`: the window is written relative to now, as it is on screen */ @@ -22,8 +24,21 @@ export interface SnippetInput { export const SNIPPET_LANGUAGES = ['python', 'curl'] as const; export type SnippetLanguage = (typeof SNIPPET_LANGUAGES)[number]; -export const INSTALL_COMMENT = - "uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'"; +const GIT_URL = 'https://github.com/SINTEF/sensapp.git'; +export const INSTALL_COMMENT = `uv pip install 'git+${GIT_URL}@main#subdirectory=python/sensapp'`; + +/** The block that `uv run` reads (PEP 723): the script says what it needs, and where the SDK comes from. */ +const SCRIPT_METADATA = [ + '# /// script', + '# requires-python = ">=3.14"', + '# dependencies = ["sensapp"]', + '#', + '# [tool.uv.sources.sensapp]', + `# git = "${GIT_URL}"`, + '# subdirectory = "python/sensapp"', + '# branch = "main"', + '# ///', +]; export const TOKEN_COMMENT = 'sensapp generate-token me --scope read'; /** A line of text for a comment. A newline in a label must not turn the rest into code. */ @@ -57,6 +72,16 @@ function py(value: string): string { return JSON.stringify(value); } +/** The same, in single quotes when that says it with fewer backslashes: `'{host="a"}'`. */ +function pyQuoted(value: string): string { + return value.includes('"') && !/['\\\p{Cc}\p{Zl}\p{Zp}]/u.test(value) ? `'${value}'` : py(value); +} + +/** What goes inside double quotes in the shell, where `$uuid` is read: the rest has to be escaped. */ +function escapeDouble(value: string): string { + return value.replace(/[\\"$`]/g, '\\$&'); +} + /** A shell word that is one word whatever it contains. */ function sh(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; @@ -86,22 +111,29 @@ function groups(input: SnippetInput): Array<{ series: SnippetInput['series']; st ]; } +/** The selector to use, if the user typed one. */ +function selectorOf(input: SnippetInput): string | undefined { + return input.selector?.trim() || undefined; +} + export function pythonSnippet(input: SnippetInput): string { const delta = input.relativeRange ? timedelta(input.relativeRange) : undefined; - const lines: string[] = []; + const selector = selectorOf(input); + const reads = input.series.length > 0 || selector !== undefined; + const lines: string[] = [...SCRIPT_METADATA]; - lines.push('import asyncio'); + lines.push('#', '# Run it with `uv run script.py`, or install the SDK first:', `# ${INSTALL_COMMENT}`); + if (input.authenticated) lines.push('#', '# The token comes from the environment, make one with:', `# export SENSAPP_TOKEN=$(${TOKEN_COMMENT})`); + lines.push('', 'import asyncio'); if (input.authenticated) lines.push('import os'); - if (delta && input.series.length > 0) lines.push('from datetime import UTC, datetime, timedelta'); - lines.push('', 'from sensapp import SensAppClient', ''); - lines.push(`# ${INSTALL_COMMENT}`); - if (input.authenticated) lines.push(`# export SENSAPP_TOKEN=$(${TOKEN_COMMENT})`); + if (delta && reads) lines.push('from datetime import UTC, datetime, timedelta'); + lines.push('', 'from sensapp import SensAppClient'); const client = input.authenticated ? [` async with SensAppClient(`, ` ${py(input.baseUrl)},`, ` token=os.environ["SENSAPP_TOKEN"],`, ` ) as client:`] : [` async with SensAppClient(${py(input.baseUrl)}) as client:`]; - if (input.series.length === 0) { + if (!reads) { lines.push('', '', 'async def main() -> None:', ...client); if (input.metric) { lines.push( @@ -120,6 +152,51 @@ export function pythonSnippet(input: SnippetInput): string { return lines.join('\n') + '\n'; } + // What the window is: now minus a duration, or the two dates + const window: string[] = delta + ? [' end = datetime.now(UTC)', ` start = end - timedelta(${delta})`] + : [` start = ${py(clean(input.timeRange.start))}`, ` end = ${py(clean(input.timeRange.end))}`]; + const [start, end] = delta ? ['start.isoformat(timespec="seconds")', 'end.isoformat(timespec="seconds")'] : ['start', 'end']; + const read = (indent: string, id: string, step?: string[]) => [ + `${indent}series = await client.get_series(`, + `${indent} ${id},`, + `${indent} start=${start},`, + `${indent} end=${end},`, + ...(step ?? []).map((line) => `${indent} ${line}`), + `${indent})`, + `${indent}print(series.name, series.labels)`, + `${indent}print(series.frame) # a Polars DataFrame: timestamp, value`, + ]; + + if (selector !== undefined) { + // The series are known when the code runs, not now: a step is for the numbers among them + lines.push('', '', 'async def main() -> None:', ...window, ...client); + lines.push( + ' catalog = await client.list_series(', + ...(input.metric ? [` metric=${py(input.metric)},`] : []), + ` selector=${pyQuoted(selector)},`, + ' )', + ' for info in catalog.series:', + ); + if (input.step) { + lines.push( + ' # A step averages numbers: the other types are read as they are', + ' numeric = info.sensor_type.lower() in ("integer", "float", "numeric")', + ); + } + lines.push( + ...read( + ' ', + 'info.uuid', + input.step ? [`step=${py(input.step)} if numeric else None,`, `aggregation=${py(input.aggregation)} if numeric else None,`] : undefined, + ), + '', + '', + 'asyncio.run(main())', + ); + return lines.join('\n') + '\n'; + } + const parts = groups(input); const mixed = parts.length > 1; const variables = parts.map((part) => (mixed && !part.step ? 'RAW_SERIES' : 'SERIES')); @@ -133,27 +210,11 @@ export function pythonSnippet(input: SnippetInput): string { lines.push(']'); }); - lines.push('', '', 'async def main() -> None:'); - if (delta) { - lines.push(' end = datetime.now(UTC)', ` start = end - timedelta(${delta})`); - } else { - lines.push(` start = ${py(clean(input.timeRange.start))}`, ` end = ${py(clean(input.timeRange.end))}`); - } - lines.push(...client); - const [start, end] = delta ? ['start.isoformat(timespec="seconds")', 'end.isoformat(timespec="seconds")'] : ['start', 'end']; + lines.push('', '', 'async def main() -> None:', ...window, ...client); parts.forEach((part, index) => { lines.push( ` for uuid in ${variables[index]}:`, - ' series = await client.get_series(', - ' uuid,', - ` start=${start},`, - ` end=${end},`, - ); - if (part.step) lines.push(` step=${py(part.step)},`, ` aggregation=${py(input.aggregation)},`); - lines.push( - ' )', - ' print(series.name, series.labels)', - ' print(series.frame) # a Polars DataFrame: timestamp, value', + ...read(' ', 'uuid', part.step ? [`step=${py(part.step)},`, `aggregation=${py(input.aggregation)},`] : undefined), ); }); lines.push('', '', 'asyncio.run(main())'); @@ -162,37 +223,62 @@ export function pythonSnippet(input: SnippetInput): string { export function curlSnippet(input: SnippetInput): string { const lines: string[] = []; + const selector = selectorOf(input); const { label, shared } = names(input.series); if (input.authenticated) { lines.push('# SENSAPP_TOKEN holds a token, made with:', `# ${TOKEN_COMMENT}`, ''); } - if (shared) lines.push(`# ${shared}`, ''); - const auth = input.authenticated ? [' -H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; - const request = (comment: string | null, url: string) => [ - ...(comment ? [`# ${comment}`] : []), - 'curl -sS --fail-with-body \\', - ...auth.map((line) => `${line} \\`), - ` ${sh(url)}`, + if (selector === undefined && shared) lines.push(`# ${shared}`, ''); + const auth = input.authenticated ? ['-H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; + + /** The lines of a request: each option on its own line, the last one the address. */ + const request = (indent: string, options: string[], url: string) => [ + `${indent}curl -sS --fail-with-body \\`, + ...[...auth, ...options].map((line) => `${indent} ${line} \\`), + `${indent} ${url}`, ]; + const readParams = (step: string | undefined) => { + const params = new URLSearchParams({ format: 'csv', start: clean(input.timeRange.start), end: clean(input.timeRange.end) }); + if (step) { + params.set('step', step); + params.set('aggregation', input.aggregation); + } + // The colons of the dates are fine in a query: leave them readable + return params.toString().replace(/%3A/g, ':'); + }; const blocks: string[][] = []; - if (input.series.length === 0) { + if (selector !== undefined) { + // The series that match, one after the other. A step averages numbers: the others are left out of that read. + const jq = input.step + ? `.["dcat:dataset"][] | select(.["sensor:type"] | ascii_downcase | IN("integer", "float", "numeric")) | .["dct:identifier"]` + : '.["dcat:dataset"][]["dct:identifier"]'; + const list = request( + '', + ['-G', ...(input.metric ? [`--data-urlencode ${sh(`metric=${input.metric}`)}`] : []), `--data-urlencode ${sh(`selector=${selector}`)}`], + `${sh(`${input.baseUrl}/series`)} |`, + ); + const each = request(' ', [], `"${escapeDouble(input.baseUrl)}/series/$uuid?${readParams(input.step)}"`); + blocks.push([ + `# The series that match ${oneLine(selector)}${input.step ? ' (the numeric ones: a step averages numbers)' : ''}`, + ...list, + ` jq -r ${sh(jq)} |`, + ' while read -r uuid; do', + ...each.map((line) => ` ${line}`), + ' done', + ]); + } else if (input.series.length === 0) { blocks.push( - input.metric - ? request(null, `${input.baseUrl}/series?${new URLSearchParams({ metric: input.metric })}`) - : request(null, `${input.baseUrl}/metrics`), + request( + '', + [], + sh(input.metric ? `${input.baseUrl}/series?${new URLSearchParams({ metric: input.metric })}` : `${input.baseUrl}/metrics`), + ), ); } else { for (const part of groups(input)) { for (const s of part.series) { - const params = new URLSearchParams({ format: 'csv', start: clean(input.timeRange.start), end: clean(input.timeRange.end) }); - if (part.step) { - params.set('step', part.step); - params.set('aggregation', input.aggregation); - } - // The colons of the dates are fine in a query: leave them readable - const query = params.toString().replace(/%3A/g, ':'); - blocks.push(request(label(s), `${input.baseUrl}/series/${encodeURIComponent(s.uuid)}?${query}`)); + blocks.push([`# ${label(s)}`, ...request('', [], sh(`${input.baseUrl}/series/${encodeURIComponent(s.uuid)}?${readParams(part.step)}`))]); } } } diff --git a/frontend/src/stores/useSelectionStore.test.ts b/frontend/src/stores/useSelectionStore.test.ts index a7f8b09c..2f62bcae 100644 --- a/frontend/src/stores/useSelectionStore.test.ts +++ b/frontend/src/stores/useSelectionStore.test.ts @@ -11,6 +11,15 @@ describe('useSelectionStore', () => { setLabelFilter(''); }); + it('forgets the selector of the series when the metric changes: it was written for the others', () => { + const { result } = renderHook(() => useSelectionStore()); + act(() => result.current.setSelectedMetric('cpu')); + act(() => result.current.setSelector('{host="a"}')); + expect(result.current.selector).toBe('{host="a"}'); + act(() => result.current.setSelectedMetric('memory')); + expect(result.current.selector).toBe(''); + }); + it('should have sensible default state', () => { const { result } = renderHook(() => useSelectionStore()); expect(result.current.selectedMetric).toBeNull(); diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index 396323ec..67ab28bb 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -52,6 +52,8 @@ interface SelectionState { step: string; aggregation: Aggregation; labelFilter: string; + /** What is typed in the selector box of the series (`{host="a"}`): the server lists the series that match */ + selector: string; setSelectedMetric: (metric: string | null) => void; toggleSeries: (series: SeriesInfo) => void; /** Shows these series too */ @@ -75,6 +77,7 @@ interface SelectionState { setChartStyle: (style: ChartStyle) => void; setLogScale: (log: boolean) => void; setLabelFilter: (filter: string) => void; + setSelector: (selector: string) => void; setStep: (step: string) => void; setAggregation: (aggregation: Aggregation) => void; } @@ -102,11 +105,12 @@ export const useSelectionStore = create((set) => ({ chartStyle: 'line', logScale: false, labelFilter: '', + selector: '', step: 'auto', aggregation: 'avg', setSelectedMetric: (metric) => - set({ selectedMetric: metric, selectedSeries: [], hoveredSeries: null }), + set({ selectedMetric: metric, selectedSeries: [], hoveredSeries: null, selector: '' }), toggleSeries: (series) => set((state) => { @@ -189,6 +193,8 @@ export const useSelectionStore = create((set) => ({ setLabelFilter: (filter) => set({ labelFilter: filter }), + setSelector: (selector) => set({ selector }), + setStep: (step) => set({ step }), setAggregation: (aggregation) => set({ aggregation }), From 91bc81b513a7b3e5b098e9eac85ae45241183c5a Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:31:17 +0200 Subject: [PATCH 32/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20cod?= =?UTF-8?q?e=20of=20the=20Code=20dialog=20wraps=20instead=20of=20scrolling?= =?UTF-8?q?=20sideways?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS only: pre-wrap, and a token with no space (a uuid, an address) breaks where it must. The copied text is the same. Looked at on desktop and at 375 px, both tabs: nothing scrolls sideways. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 2 +- frontend/src/index.css | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 43fca492..37d731b8 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -40,7 +40,7 @@ The **Code** button of the header opens the code that loads what the explorer sh - **A selector** typed in the box of the series list (`{host="a"}`, with the metric) is what the code asks for, instead of the uuids: `list_series(metric=…, selector=…)` then `get_series` of each in Python, and `curl -G --data-urlencode` into `jq` and a loop in the shell (`jq` is needed). The code then reads every series that matches, whenever it runs, which is not always what is checked: the checkbox above the code goes back to the selected series. The selector is forgotten when another metric is chosen. As the series are not known when the code is written, a step is only asked for the numbers among them (Python looks at `sensor_type`, which the server writes `Float`; the shell loop leaves the others out and says so). A selector that matches more than a page (256 series) reads the first page. - With no series selected, and no selector, it lists the series of the metric, and with no metric the metrics. - Whatever a label or a name says is quoted (Python and shell) or put on one line (comments): a hostile label cannot add a line of code to what is copied. -- The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. +- The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). Long lines wrap instead of scrolling sideways (a uuid or an address with no space breaks where it must); the copied text is unchanged. The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. - The Python SDK is not on PyPI yet. The script starts with a [PEP 723](https://peps.python.org/pep-0723/) block (`# /// script`) that says it needs `sensapp` and that it comes from GitHub (`[tool.uv.sources.sensapp]`), so `uv run script.py` is enough: uv makes the environment. The `uv pip install` command is there too, for an environment of one's own. Both are in `src/lib/snippets.ts` (`SCRIPT_METADATA`, `INSTALL_COMMENT`): change them when the SDK is published, and the Python the block asks for follows `requires-python` of the SDK. ## Address diff --git a/frontend/src/index.css b/frontend/src/index.css index b5ae82d1..808e7a9b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -199,6 +199,9 @@ html, body, #root { line-height: 1.65; font-variant-ligatures: none; tab-size: 4; + /* Long lines wrap (no sideways scroll); a token with no space, such as a uuid or an address, breaks where it must */ + white-space: pre-wrap; + overflow-wrap: anywhere; } .code-block .hljs-comment { color: #8a98aa; font-style: italic; } .code-block .hljs-keyword, From 67c645f2590a798fa9710faa1db5b8e67c16fe8f Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:46:16 +0200 Subject: [PATCH 33/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20a=20Load?= =?UTF-8?q?=20Data=20tab,=20with=20Python=20SDK,=20Telegraf,=20Prometheus?= =?UTF-8?q?=20and=20curl=20snippets=20to=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header gets two tabs, Data Explorer and Load Data (/ui/load, loaded on demand, the way in ?via=). The snippets take the address of the server, read the token from SENSAPP_TOKEN when it asks for one, and were all run against a real SensApp, with and without a JWT secret. Telegraf sends 'Authorization: Token', SensApp reads 'Bearer': the snippet overrides the header, and ideas/influxdb-token-authorization-scheme.md notes the server-side fix. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 15 +- done/frontend-load-data-tab.md | 38 +++ frontend/src/App.tsx | 12 + frontend/src/components/CodeBlock.tsx | 37 +++ frontend/src/components/CodeDialog.tsx | 12 +- frontend/src/components/Layout.tsx | 32 ++- frontend/src/lib/highlight.ts | 18 ++ frontend/src/lib/loadSnippets.test.ts | 65 +++++ frontend/src/lib/loadSnippets.ts | 287 +++++++++++++++++++ frontend/src/lib/snippets.ts | 2 +- frontend/src/pages/LoadPage.test.tsx | 104 +++++++ frontend/src/pages/LoadPage.tsx | 107 +++++++ ideas/influxdb-token-authorization-scheme.md | 12 + 13 files changed, 725 insertions(+), 16 deletions(-) create mode 100644 done/frontend-load-data-tab.md create mode 100644 frontend/src/components/CodeBlock.tsx create mode 100644 frontend/src/lib/highlight.ts create mode 100644 frontend/src/lib/loadSnippets.test.ts create mode 100644 frontend/src/lib/loadSnippets.ts create mode 100644 frontend/src/pages/LoadPage.test.tsx create mode 100644 frontend/src/pages/LoadPage.tsx create mode 100644 ideas/influxdb-token-authorization-scheme.md diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 37d731b8..edbb2fca 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -1,6 +1,6 @@ # Web UI -SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. +SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. A second tab, **Load Data**, explains how to get data in. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. ## Serving @@ -43,6 +43,19 @@ The **Code** button of the header opens the code that loads what the explorer sh - The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). Long lines wrap instead of scrolling sideways (a uuid or an address with no space breaks where it must); the copied text is unchanged. The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. - The Python SDK is not on PyPI yet. The script starts with a [PEP 723](https://peps.python.org/pep-0723/) block (`# /// script`) that says it needs `sensapp` and that it comes from GitHub (`[tool.uv.sources.sensapp]`), so `uv run script.py` is enough: uv makes the environment. The `uv pip install` command is there too, for an environment of one's own. Both are in `src/lib/snippets.ts` (`SCRIPT_METADATA`, `INSTALL_COMMENT`): change them when the SDK is published, and the Python the block asks for follows `requires-python` of the SDK. +## Load Data + +The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). + +- **Python SDK**: a whole DataFrame (sent by slices of 100 000 samples, as [PYTHON_SDK.md](PYTHON_SDK.md#timeouts) advises), one sample at a time, a few samples at a time. Same `uv run` header as the Code dialog. +- **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write` with a few inputs (see [INFLUX_DB.md](INFLUX_DB.md)), and how to check it (`--once`). +- **Prometheus**: `remote_write` and `remote_read` (`read_recent: true`), and the address to use from a container. +- **curl**: SenML JSON, CSV and InfluxDB line protocol, to `/publish` and `/api/v2/write`. +- The server is the origin of the page. When it asked for a token (or one is in use) the code reads it from `SENSAPP_TOKEN` and the page says how to make a `write` one (`sensapp generate-token me --scope write`); **no token is ever written in the code**. Prometheus reads its token from a file, so its tab gives the command that writes it (a year long, for reading and writing). +- **Telegraf and tokens.** Telegraf sends its `token` as `Authorization: Token …`, the InfluxDB way, and SensApp only reads `Bearer …` (it answers `401`). The snippet therefore sets `http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}`, which Telegraf applies after its own header. Checked against a server with `SENSAPP_JWT_SECRET`. +- The snippets are in `src/lib/loadSnippets.ts` (one function for each way, tested), the page in `src/pages/LoadPage.tsx`. The page is loaded when its tab is first opened (5 kB gzipped, the highlighter is shared with the Code dialog). +- All of them were run against a real SensApp (PostgreSQL), with and without a JWT secret: the Python scripts, the curl commands, Telegraf (`--once`), and the Prometheus configuration with `promtool check config`. + ## Address The explorer is in the address, so a link shares a view and a reload keeps it: `/ui/?metric=cpu&series=&series=&range=24h&step=5m&agg=max&style=area&log=1`. diff --git a/done/frontend-load-data-tab.md b/done/frontend-load-data-tab.md new file mode 100644 index 00000000..aed24c84 --- /dev/null +++ b/done/frontend-load-data-tab.md @@ -0,0 +1,38 @@ +# Frontend: a "Load Data" tab + +Branch: `frontend-good-enough`. + +## Goal + +The header gets two tabs: **Data Explorer** (`/ui/`) and **Load Data** (`/ui/load`). The second one explains how +to get data into SensApp, with snippets that are ready to copy (same dark code block and copy button as the Code +dialog), filled with the address of the server the page is served from. + +Ways, as tabs of the page (`?via=` in the address): Python SDK, Telegraf, Prometheus, curl. Kept short on purpose: +enough to start, not a reference (the reference is `/docs`). + +- **Python SDK**: a big DataFrame (sent by slices), one sample at a time, a few samples at a time. +- **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write`, with inputs. +- **Prometheus**: `remote_write` and `remote_read`. +- **curl**: SenML JSON, CSV, InfluxDB line protocol. + +When the server asks for a token the snippets read it from `SENSAPP_TOKEN` and say how to make a `write` one. + +## Steps + +1. [x] Header tabs, `/load` route (lazy), shared code block, snippets in `src/lib/loadSnippets.ts` + tests +2. [x] Check every snippet against a real SensApp (Python, Telegraf, curl, with and without a JWT secret) +3. [x] `docs/FRONTEND.md`, move this file to `done/` + +## Findings + +- All snippets were run against a real SensApp (PostgreSQL, one instance open and one with a JWT secret): 43 200 + samples of a DataFrame arrived in slices, curl's three formats, Telegraf with `--once`, and `promtool check config` + accepts the Prometheus files. (No live Prometheus run: the endpoints are covered by the server tests.) +- **Telegraf's `token` does not work with JWT**: it sends `Authorization: Token ...`, SensApp wants `Bearer`, and + answered `401`. The snippet overrides the header with `http_headers`, checked live. Written in + `ideas/influxdb-token-authorization-scheme.md`. +- InfluxDB measurement and field make the series name with a space (`cpu usage_idle`), not an underscore. +- Prometheus's token is a file: the tab gives the command, with a year of validity (the default is an hour). +- The Code button is about the explorer, so it is hidden on the Load Data tab. +- Not done on purpose: Grafana, MQTT, other languages, reading data back on this tab (the Code dialog does it). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ff0085b..ad8c8aa9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,24 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; import { Layout } from './components/Layout'; import { ExplorerPage } from './pages/ExplorerPage'; +// The page that explains how to load data brings the code highlighter: it is fetched when the tab is first opened +const LoadPage = lazy(() => import('./pages/LoadPage')); + function App() { return ( }> } /> + + + + } + /> ); diff --git a/frontend/src/components/CodeBlock.tsx b/frontend/src/components/CodeBlock.tsx new file mode 100644 index 00000000..e75fec83 --- /dev/null +++ b/frontend/src/components/CodeBlock.tsx @@ -0,0 +1,37 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { copyText } from '../lib/copyText'; +import { highlight } from '../lib/highlight'; + +/** Code on a dark ground, in both themes, with a button that copies it. */ +export function CodeBlock({ code, language, label }: { code: string; language: Parameters[1]; label: string }) { + const [copied, setCopied] = useState(null); + const html = useMemo(() => highlight(code, language), [code, language]); + + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); + + async function handleCopy() { + setCopied(await copyText(code)); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(null), 2000); + } + + return ( +

+
+        
+      
+ + + {copied === true ? 'Copied to the clipboard' : copied === false ? 'Could not copy' : ''} + +
+ ); +} diff --git a/frontend/src/components/CodeDialog.tsx b/frontend/src/components/CodeDialog.tsx index a13259e6..636ad6ab 100644 --- a/frontend/src/components/CodeDialog.tsx +++ b/frontend/src/components/CodeDialog.tsx @@ -1,20 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { KeyboardEvent } from 'react'; -import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import python from 'highlight.js/lib/languages/python'; import { resolveStep } from '../lib/chartStep'; import { copyText } from '../lib/copyText'; +import { highlight } from '../lib/highlight'; import { SNIPPET_LANGUAGES, snippetFor } from '../lib/snippets'; import type { SnippetLanguage } from '../lib/snippets'; import { useAuthStore } from '../stores/useAuthStore'; import { useSelectionStore } from '../stores/useSelectionStore'; -hljs.registerLanguage('python', python); -hljs.registerLanguage('bash', bash); - const LABELS: Record = { python: 'Python', curl: 'curl' }; -const HIGHLIGHT_AS: Record = { python: 'python', curl: 'bash' }; +const HIGHLIGHT_AS = { python: 'python', curl: 'bash' } as const; /** Code that loads what the explorer shows: the Python SDK, or curl. */ export default function CodeDialog({ onClose }: { onClose: () => void }) { @@ -42,8 +37,7 @@ export default function CodeDialog({ onClose }: { onClose: () => void }) { }), [language, selectedMetric, selectedSeries, useSelector, typedSelector, timeRange, relativeRange, step, aggregation, token, authRequired], ); - // highlight.js escapes the text it is given: what it returns is safe to put in the page - const html = useMemo(() => hljs.highlight(code, { language: HIGHLIGHT_AS[language] }).value, [code, language]); + const html = useMemo(() => highlight(code, HIGHLIGHT_AS[language]), [code, language]); const timer = useRef(undefined); useEffect(() => () => window.clearTimeout(timer.current), []); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2453414f..bda97546 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { Link, Outlet } from 'react-router-dom'; +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; import { AuthDialog } from './AuthDialog'; import { AuthStatus } from './AuthStatus'; import { CodeButton } from './CodeButton'; @@ -10,7 +10,14 @@ const logoWhite = `${base}sensapp_logo_white-fs8.png`; const logoSmall = `${base}sensapp_logo_small-fs8.png`; const logoSmallWhite = `${base}sensapp_logo_small_white-fs8.png`; +const TABS = [ + { to: '/', label: 'Data Explorer', end: true }, + { to: '/load', label: 'Load Data', end: false }, +]; + export function Layout() { + // The Code button is about what the explorer shows + const explorer = useLocation().pathname === '/'; return (
@@ -26,13 +33,28 @@ export function Layout() { SensApp -
- Data Explorer -
+
- + {explorer && } [0], input: LoadInput) => sectionsFor(way, input).map((s) => s.code).join('\n'); + +describe('load snippets', () => { + it('say where the server is, in every way', () => { + for (const way of LOAD_WAYS) { + expect(codeOf(way.id, open), way.id).toContain('sensapp.example:3000'); + } + }); + + it('never carry a token, and read it from the environment only when the server asks for one', () => { + for (const way of LOAD_WAYS) { + expect(codeOf(way.id, open), way.id).not.toContain('SENSAPP_TOKEN'); + const code = codeOf(way.id, secured); + // Prometheus reads a file, the others the environment + expect(code, way.id).toContain(way.id === 'prometheus' ? 'generate-token prometheus --scope readwrite' : 'SENSAPP_TOKEN'); + if (way.id !== 'prometheus') expect(code, way.id).toContain('generate-token me --scope write'); + expect(code, way.id).not.toMatch(/eyJ/); + } + }); + + it('python: three scripts that tell uv what they need, each opening the client', () => { + const sections = sectionsFor('python', open); + expect(sections.map((s) => s.title)).toEqual(['A whole DataFrame', 'One sample at a time', 'A few samples at a time']); + for (const section of sections) { + expect(section.language).toBe('python'); + expect(section.code.startsWith('# /// script')).toBe(true); + expect(section.code).toContain('async with SensAppClient("http://sensapp.example:3000") as client:'); + } + expect(sections[0].code).toContain('frame.iter_slices(100_000)'); + expect(sections[2].code).toContain('SamplePoint(datetime.now(UTC)'); + }); + + it('python: the client takes the token of the environment', () => { + const code = codeOf('python', secured); + expect(code).toContain('import os'); + expect(code).toContain('token=os.environ["SENSAPP_TOKEN"],'); + }); + + it('telegraf: sends a Bearer token, not the Token of InfluxDB, which SensApp does not read', () => { + expect(codeOf('telegraf', open)).toContain('urls = ["http://sensapp.example:3000"]'); + expect(codeOf('telegraf', open)).not.toContain('http_headers'); + expect(codeOf('telegraf', secured)).toContain('http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}'); + }); + + it('prometheus: writes and reads', () => { + const code = codeOf('prometheus', open); + expect(code).toContain('url: http://sensapp.example:3000/api/v1/prometheus_remote_write'); + expect(code).toContain('url: http://sensapp.example:3000/api/v1/prometheus_remote_read'); + expect(codeOf('prometheus', secured)).toContain('credentials_file'); + }); + + it('curl: puts every address in quotes and the token in a header', () => { + const code = codeOf('curl', secured); + expect(code).toContain("'http://sensapp.example:3000/publish'"); + expect(code).toContain("'http://sensapp.example:3000/api/v2/write?org=sensapp&bucket=home&precision=s'"); + expect(code).toContain('-H "Authorization: Bearer $SENSAPP_TOKEN"'); + }); +}); diff --git a/frontend/src/lib/loadSnippets.ts b/frontend/src/lib/loadSnippets.ts new file mode 100644 index 00000000..908d0e57 --- /dev/null +++ b/frontend/src/lib/loadSnippets.ts @@ -0,0 +1,287 @@ +import { SCRIPT_METADATA } from './snippets'; + +/** What the snippets need to know: where SensApp is, and whether it asks for a token. */ +export interface LoadInput { + /** The origin of the SensApp server */ + baseUrl: string; + /** The server asked for a token: the snippets read it from `SENSAPP_TOKEN`, they never contain one */ + authenticated: boolean; +} + +export type LoadLanguage = 'python' | 'bash' | 'toml' | 'yaml'; + +/** A piece of the page: a title, a sentence, and the code that goes with it. */ +export interface LoadSection { + title: string; + description: string; + language: LoadLanguage; + code: string; +} + +export const LOAD_WAYS = [ + { id: 'python', label: 'Python SDK' }, + { id: 'telegraf', label: 'Telegraf' }, + { id: 'prometheus', label: 'Prometheus' }, + { id: 'curl', label: 'curl' }, +] as const; +export type LoadWay = (typeof LOAD_WAYS)[number]['id']; + +/** What makes a token that can write. The read one of the Code dialog cannot. */ +export const WRITE_TOKEN_COMMAND = 'sensapp generate-token me --scope write'; + +/** A Python string literal. A JSON string is one, with its escapes. */ +const py = (value: string) => JSON.stringify(value); + +/** A shell word that is one word whatever it contains. */ +const sh = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +/** The client of the SDK, as `async with` opens it, indented by `indent`. */ +function pythonClient(input: LoadInput, indent: string): string[] { + return input.authenticated + ? [`${indent}async with SensAppClient(`, `${indent} ${py(input.baseUrl)},`, `${indent} token=os.environ["SENSAPP_TOKEN"],`, `${indent}) as client:`] + : [`${indent}async with SensAppClient(${py(input.baseUrl)}) as client:`]; +} + +/** The head of a script: the block `uv run` reads, then the imports. */ +function pythonHead(input: LoadInput, file: string, imports: string[]): string[] { + return [ + ...SCRIPT_METADATA, + '#', + `# Run it with \`uv run ${file}\``, + ...(input.authenticated ? ['#', '# The token comes from the environment, make one with:', `# export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})`] : []), + '', + ...imports, + ]; +} + +export function pythonSections(input: LoadInput): LoadSection[] { + const os = input.authenticated ? ['import os'] : []; + + const frame = [ + ...pythonHead(input, 'load.py', ['import asyncio', 'from datetime import UTC, datetime, timedelta', ...os, '', 'import polars as pl', 'from sensapp import SensAppClient']), + '', + '# A table of samples: a "timestamp" column with a time zone, and a "value" column.', + '# Read yours instead: pl.read_csv("measurements.csv", try_parse_dates=True), pl.read_parquet(...)', + '# or pl.from_pandas(df). A timestamp with no time zone is UTC after .dt.replace_time_zone("UTC").', + 'start = datetime.now(UTC) - timedelta(days=30)', + 'frame = pl.DataFrame(', + ' {', + ' "timestamp": [start + timedelta(minutes=i) for i in range(43_200)],', + ' "value": [20 + (i % 1440) / 100 for i in range(43_200)],', + ' }', + ')', + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' # A history goes by slices: a request is not meant to carry everything', + ' for part in frame.iter_slices(100_000):', + ' await client.publish("temperature", part)', + '', + '', + 'asyncio.run(main())', + ]; + + const one = [ + ...pythonHead(input, 'sample.py', ['import asyncio', ...os, '', 'from sensapp import SensAppClient']), + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' # The sample is stamped with the time it is sent', + ' await client.publish("temperature", 21.5)', + '', + '', + 'asyncio.run(main())', + ]; + + const few = [ + ...pythonHead(input, 'stream.py', ['import asyncio', 'import random', 'from datetime import UTC, datetime', ...os, '', 'from sensapp import SamplePoint, SensAppClient']), + '', + '', + 'def read_temperature() -> float:', + ' return 20 + random.random() # your sensor here', + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' batch: list[SamplePoint] = []', + ' while True:', + ' # Each sample keeps the time it was read', + ' batch.append(SamplePoint(datetime.now(UTC), read_temperature()))', + ' if len(batch) >= 10:', + ' await client.publish("temperature", batch)', + ' batch.clear()', + ' await asyncio.sleep(1)', + '', + '', + 'asyncio.run(main())', + ]; + + return [ + { + title: 'A whole DataFrame', + description: 'A history, or a file you have: send the table, SensApp keeps its timestamps. Publishing the same name again adds to the same series.', + language: 'python', + code: frame.join('\n') + '\n', + }, + { + title: 'One sample at a time', + description: 'The simplest thing that works, for a sensor that reports now and then.', + language: 'python', + code: one.join('\n') + '\n', + }, + { + title: 'A few samples at a time', + description: 'A sensor that reports often: keep the samples for a moment and send them together, which costs the server less than a request each.', + language: 'python', + code: few.join('\n') + '\n', + }, + ]; +} + +export function telegrafSections(input: LoadInput): LoadSection[] { + const config = [ + '[agent]', + ' interval = "10s"', + ' flush_interval = "10s"', + '', + '# SensApp speaks the InfluxDB v2 write API', + '[[outputs.influxdb_v2]]', + ` urls = [${py(input.baseUrl)}]`, + ' organization = "sensapp"', + ' bucket = "telegraf"', + ' content_encoding = "gzip"', + ' influx_uint_support = true', + ...(input.authenticated + ? [ + ' # Telegraf sends its token as "Authorization: Token ...", SensApp wants "Bearer".', + ' # Make a token with: ' + WRITE_TOKEN_COMMAND, + ' # and start Telegraf with it in SENSAPP_TOKEN.', + ' http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}', + ] + : []), + '', + '# Something to measure', + '[[inputs.cpu]]', + ' percpu = true', + ' totalcpu = true', + '', + '[[inputs.mem]]', + '', + '[[inputs.disk]]', + ' ignore_fs = ["tmpfs", "devtmpfs", "devfs", "overlay", "squashfs"]', + ]; + return [ + { + title: 'telegraf.conf', + description: + 'Telegraf writes as it would to InfluxDB. A measurement and a field make the name of a series (`cpu` and `usage_idle` give `cpu usage_idle`), the tags become its labels, and `organization` and `bucket` are kept as the labels `influxdb_org` and `influxdb_bucket`.', + language: 'toml', + code: config.join('\n') + '\n', + }, + { + title: 'Run it', + description: 'Check it with one collection, then leave it running.', + language: 'bash', + code: [ + ...(input.authenticated ? [`export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})`] : []), + 'telegraf --config telegraf.conf --once', + 'telegraf --config telegraf.conf', + ].join('\n') + '\n', + }, + ]; +} + +const PROMETHEUS_TOKEN_FILE = '/etc/prometheus/sensapp.token'; + +/** Prometheus reads its token from a file, for writing and for reading. */ +const PROMETHEUS_TOKEN_COMMAND = `sensapp generate-token prometheus --scope readwrite --duration 31536000 > ${PROMETHEUS_TOKEN_FILE}`; + +export function prometheusSections(input: LoadInput): LoadSection[] { + const authorization = input.authenticated + ? [' authorization:', ` credentials_file: ${PROMETHEUS_TOKEN_FILE}`] + : []; + const config = [ + 'remote_write:', + ' - name: sensapp', + ` url: ${input.baseUrl}/api/v1/prometheus_remote_write`, + ...authorization, + '', + 'remote_read:', + ' - name: sensapp', + ` url: ${input.baseUrl}/api/v1/prometheus_remote_read`, + ' read_recent: true', + ...authorization, + ]; + return [ + ...(input.authenticated + ? [ + { + title: 'A token', + description: 'This server asks for one. Prometheus reads it from a file; this one writes and reads, and is good for a year (the default is an hour).', + language: 'bash' as const, + code: PROMETHEUS_TOKEN_COMMAND + '\n', + }, + ] + : []), + { + title: 'prometheus.yml', + description: + 'Every sample Prometheus scrapes is also sent to SensApp. The labels are kept as they are, and the metric name is the name of the series. Add the remote_read to query SensApp from Prometheus and Grafana too; `read_recent` makes Prometheus ask it for recent data as well, not only for what its own storage has dropped.', + language: 'yaml', + code: config.join('\n') + '\n', + }, + { + title: 'Prometheus in a container', + description: 'A container does not reach SensApp at `localhost`: use `host.docker.internal` (Docker Desktop) or the name of the SensApp service in the same network.', + language: 'yaml', + code: ['remote_write:', ' - url: http://host.docker.internal:3000/api/v1/prometheus_remote_write', ''].join('\n'), + }, + ]; +} + +export function curlSections(input: LoadInput): LoadSection[] { + const header = input.authenticated ? ['-H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; + const token = input.authenticated ? [`# SENSAPP_TOKEN holds a token, made with:`, `# ${WRITE_TOKEN_COMMAND}`, ''] : []; + /** One request: each option on its own line, the address last. */ + const request = (options: string[], url: string) => ['curl -sS --fail-with-body \\', ...[...header, ...options].map((line) => ` ${line} \\`), ` ${url}`]; + const publish = sh(`${input.baseUrl}/publish`); + const write = sh(`${input.baseUrl}/api/v2/write?org=sensapp&bucket=home&precision=s`); + + const senml = [ + ...token, + '# "n" is the name, "u" the unit and "v" the value. "t" is a Unix time in seconds: left out, it is now.', + ...request(['--json ' + sh('[{"n":"temperature","u":"Cel","v":21.5},{"n":"humidity","u":"%RH","v":41,"t":1767225600}]')], publish), + ]; + const csv = [ + ...token, + '# A file with a datetime column, a sensor_name column and a value column (and a unit one, if you like)', + "printf 'datetime,sensor_name,value,unit\\n2026-01-01T12:00:00Z,temperature,21.5,Cel\\n2026-01-01T12:01:00Z,temperature,21.7,Cel\\n' > measurements.csv", + '', + ...request(['-H ' + sh('content-type: text/csv'), '--data-binary @measurements.csv'], publish), + ]; + const line = [ + ...token, + '# measurement,tag=value field=value timestamp: the tags become labels. precision=s says the timestamp is in seconds.', + ...request(['--data-binary ' + sh('weather,location=oslo temperature=21.5,humidity=41i 1767225600')], write), + ]; + return [ + { title: 'SenML JSON', description: 'The standard format for sensor data (RFC 8428), and the one that needs the least.', language: 'bash', code: senml.join('\n') + '\n' }, + { title: 'CSV', description: 'A file out of a spreadsheet or a logger. SensApp finds the columns by their names.', language: 'bash', code: csv.join('\n') + '\n' }, + { title: 'InfluxDB line protocol', description: 'What Telegraf and many devices speak. `org` and `bucket` are required, as for InfluxDB.', language: 'bash', code: line.join('\n') + '\n' }, + ]; +} + +export function sectionsFor(way: LoadWay, input: LoadInput): LoadSection[] { + switch (way) { + case 'python': + return pythonSections(input); + case 'telegraf': + return telegrafSections(input); + case 'prometheus': + return prometheusSections(input); + case 'curl': + return curlSections(input); + } +} diff --git a/frontend/src/lib/snippets.ts b/frontend/src/lib/snippets.ts index 31f4eaa1..522b4cff 100644 --- a/frontend/src/lib/snippets.ts +++ b/frontend/src/lib/snippets.ts @@ -28,7 +28,7 @@ const GIT_URL = 'https://github.com/SINTEF/sensapp.git'; export const INSTALL_COMMENT = `uv pip install 'git+${GIT_URL}@main#subdirectory=python/sensapp'`; /** The block that `uv run` reads (PEP 723): the script says what it needs, and where the SDK comes from. */ -const SCRIPT_METADATA = [ +export const SCRIPT_METADATA = [ '# /// script', '# requires-python = ">=3.14"', '# dependencies = ["sensapp"]', diff --git a/frontend/src/pages/LoadPage.test.tsx b/frontend/src/pages/LoadPage.test.tsx new file mode 100644 index 00000000..3209a3db --- /dev/null +++ b/frontend/src/pages/LoadPage.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App'; +import { useAuthStore } from '../stores/useAuthStore'; + +// The explorer is the other tab: it finds nothing +const emptyCatalog = { '@context': {}, '@type': 'dcat:Catalog', '@id': 'empty', 'dcat:dataset': [] }; +vi.mock('../client', () => ({ + listMetrics: () => Promise.resolve({ data: emptyCatalog }), + listSeries: () => Promise.resolve({ data: emptyCatalog }), + getSeriesData: () => Promise.resolve({ data: [] }), +})); + +beforeEach(() => { + useAuthStore.setState({ token: null, authRequired: false }); +}); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +const code = () => screen.getByRole('tabpanel').textContent ?? ''; + +describe('LoadPage', () => { + it('is the second tab of the header, next to the explorer', async () => { + const user = userEvent.setup(); + renderAt('/'); + const nav = screen.getByRole('navigation', { name: 'Pages' }); + expect(nav).toHaveTextContent('Data Explorer'); + expect(screen.getByRole('link', { name: 'Data Explorer' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: 'Load Data' })); + expect(await screen.findByRole('heading', { name: 'Load data into SensApp' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Load Data' })).toHaveAttribute('aria-current', 'page'); + // The Code button is about the explorer + expect(screen.queryByRole('button', { name: 'Code' })).toBeNull(); + }); + + it('opens on the Python SDK, then goes from a way to the other', async () => { + const user = userEvent.setup(); + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Python SDK' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain('from sensapp import SensAppClient'); + expect(code()).toContain('async with SensAppClient('); + + await user.click(screen.getByRole('tab', { name: 'Telegraf' })); + expect(code()).toContain('[[outputs.influxdb_v2]]'); + await user.click(screen.getByRole('tab', { name: 'Prometheus' })); + expect(code()).toContain('remote_write:'); + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(code()).toContain('/api/v2/write'); + }); + + it('opens on the way the address names, and ignores one it does not know', async () => { + renderAt('/load?via=prometheus'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Prometheus' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('falls back to the Python SDK for an unknown way', async () => { + renderAt('/load?via=cobol'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Python SDK' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('goes from a tab to the other with the arrow keys', async () => { + const user = userEvent.setup(); + renderAt('/load'); + await screen.findByRole('tabpanel'); + screen.getByRole('tab', { name: 'Python SDK' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('tab', { name: 'Telegraf' })).toHaveFocus(); + await user.keyboard('{ArrowLeft}{ArrowLeft}'); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveFocus(); + }); + + it('colors the code, and copies it', async () => { + const user = userEvent.setup(); + renderAt('/load?via=curl'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tabpanel').querySelector('span.hljs-string')).not.toBeNull(); + await user.click(screen.getByRole('button', { name: 'Copy SenML JSON' })); + expect(await screen.findByRole('button', { name: 'Copy SenML JSON' })).toHaveTextContent('Copied'); + }); + + it('says how to make a token when the server asks for one', async () => { + useAuthStore.setState({ token: null, authRequired: true }); + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tabpanel')).toHaveTextContent('sensapp generate-token me --scope write'); + expect(code()).toContain('os.environ["SENSAPP_TOKEN"]'); + }); +}); diff --git a/frontend/src/pages/LoadPage.tsx b/frontend/src/pages/LoadPage.tsx new file mode 100644 index 00000000..7dc687d0 --- /dev/null +++ b/frontend/src/pages/LoadPage.tsx @@ -0,0 +1,107 @@ +import { Fragment, useState } from 'react'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { CodeBlock } from '../components/CodeBlock'; +import { LOAD_WAYS, WRITE_TOKEN_COMMAND, sectionsFor } from '../lib/loadSnippets'; +import type { LoadWay } from '../lib/loadSnippets'; +import { useAuthStore } from '../stores/useAuthStore'; + +/** `code` between backticks is shown as code. */ +function withCode(text: string): ReactNode { + return text.split('`').map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +function isWay(value: string | null): value is LoadWay { + return LOAD_WAYS.some((way) => way.id === value); +} + +/** How to get data into SensApp: a way for each tab, and the code to copy. */ +export function LoadPage() { + const [params, setParams] = useSearchParams(); + const asked = params.get('via'); + const [way, setWay] = useState(isWay(asked) ? asked : 'python'); + const { token, authRequired } = useAuthStore(); + const authenticated = token !== null || authRequired; + + function choose(next: LoadWay) { + setWay(next); + // The way is in the address so that a link shares it, without a history entry for a click + setParams(next === 'python' ? {} : { via: next }, { replace: true }); + } + + // Left and right go from a tab to the other, as in any tab list + function handleTabKey(event: KeyboardEvent) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const index = LOAD_WAYS.findIndex((item) => item.id === way); + const step = event.key === 'ArrowRight' ? 1 : LOAD_WAYS.length - 1; + const next = LOAD_WAYS[(index + step) % LOAD_WAYS.length].id; + choose(next); + document.getElementById(`load-tab-${next}`)?.focus(); + } + + const sections = sectionsFor(way, { + baseUrl: import.meta.env.VITE_SENSAPP_API_URL || window.location.origin, + authenticated, + }); + + return ( +
+
+ + +
+ {LOAD_WAYS.map((item) => ( + + ))} +
+ +
+ {authenticated && ( +

+ This server asks for a token. Loading needs the write scope: make one with{' '} + {WRITE_TOKEN_COMMAND}. The code reads it from SENSAPP_TOKEN. +

+ )} + {sections.map((section) => ( +
+
+

{section.title}

+

{withCode(section.description)}

+
+ +
+ ))} +
+
+
+ ); +} + +export default LoadPage; diff --git a/ideas/influxdb-token-authorization-scheme.md b/ideas/influxdb-token-authorization-scheme.md new file mode 100644 index 00000000..6c31b71c --- /dev/null +++ b/ideas/influxdb-token-authorization-scheme.md @@ -0,0 +1,12 @@ +# InfluxDB clients send `Authorization: Token`, SensApp reads `Bearer` + +Found while writing the Load Data tab (5 October 2026). Telegraf's `outputs.influxdb_v2` sends its `token` as +`Authorization: Token `, as InfluxDB v2 clients do. `src/http/auth.rs` only strips `Bearer `, so with +`SENSAPP_JWT_SECRET` set a stock Telegraf (or the InfluxDB client libraries) gets `401`. + +Today's workaround, in the UI snippet and `docs/FRONTEND.md`: `http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}`. + +The idea: accept the `Token ` scheme as well on the InfluxDB write route (`/api/v2/write`) only, or everywhere +(one more `strip_prefix`). It makes "update the URL and credentials", the promise of `docs/INFLUX_DB.md`, true with +authentication on. Small change, with a test on each route; `docs/JWT_AUTH.md` and `docs/INFLUX_DB.md` to update, +then the `http_headers` lines of the Load Data snippet (`src/lib/loadSnippets.ts`) to drop. From 52ddb823e02dcec77d06355852259517d4837d18 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:48:20 +0200 Subject: [PATCH 34/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20Load=20Da?= =?UTF-8?q?ta=20is=20a=20plain=20full-width=20page,=20with=20underlined=20?= =?UTF-8?q?tabs=20and=20the=20text=20beside=20the=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 2 +- frontend/src/pages/LoadPage.tsx | 86 ++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index edbb2fca..a8be32c6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -45,7 +45,7 @@ The **Code** button of the header opens the code that loads what the explorer sh ## Load Data -The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). +The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. No card: the page uses the whole width, with underlined tabs like the header's, and a section is a title and a sentence on the left, its code on the right (stacked on a narrow screen). The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). - **Python SDK**: a whole DataFrame (sent by slices of 100 000 samples, as [PYTHON_SDK.md](PYTHON_SDK.md#timeouts) advises), one sample at a time, a few samples at a time. Same `uv run` header as the Code dialog. - **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write` with a few inputs (see [INFLUX_DB.md](INFLUX_DB.md)), and how to check it (`--once`). diff --git a/frontend/src/pages/LoadPage.tsx b/frontend/src/pages/LoadPage.tsx index 7dc687d0..2d8143e1 100644 --- a/frontend/src/pages/LoadPage.tsx +++ b/frontend/src/pages/LoadPage.tsx @@ -10,7 +10,7 @@ import { useAuthStore } from '../stores/useAuthStore'; function withCode(text: string): ReactNode { return text.split('`').map((part, index) => index % 2 === 1 ? ( - + {part} ) : ( @@ -54,52 +54,60 @@ export function LoadPage() { }); return ( -
-
-
-

Load data into SensApp

-

- Pick the way that fits your data, copy the code, and run it. The address in it is the one of this server. - Everything else SensApp reads is in the API docs. -

-
+
+
+

Load data into SensApp

+

+ Pick the way that fits your data, copy the code, and run it. The address in it is the one of this server. Everything else SensApp + reads is in the{' '} + + API docs + + . +

+
-
- {LOAD_WAYS.map((item) => ( - - ))} -
+
+ {LOAD_WAYS.map((item) => ( + + ))} +
-
- {authenticated && ( -

- This server asks for a token. Loading needs the write scope: make one with{' '} - {WRITE_TOKEN_COMMAND}. The code reads it from SENSAPP_TOKEN. -

- )} +
+ {authenticated && ( +

+ This server asks for a token. Loading needs the write scope: make one with{' '} + {WRITE_TOKEN_COMMAND}. The code reads it from SENSAPP_TOKEN. +

+ )} +
{sections.map((section) => ( -
+
-

{section.title}

-

{withCode(section.description)}

+

{section.title}

+

{withCode(section.description)}

-
+
))}
- +
); } From 44f115d39d8da84c91ec748410802c31450042f0 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 12:54:05 +0200 Subject: [PATCH 35/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20Load=20Da?= =?UTF-8?q?ta=20scrolls,=20loses=20its=20title=20and=20subtitle,=20and=20s?= =?UTF-8?q?ays=20less?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 2 +- frontend/src/components/Layout.tsx | 2 +- frontend/src/lib/loadSnippets.test.ts | 2 +- frontend/src/lib/loadSnippets.ts | 39 +++++++++++++-------------- frontend/src/pages/LoadPage.test.tsx | 2 +- frontend/src/pages/LoadPage.tsx | 14 +--------- 6 files changed, 23 insertions(+), 38 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a8be32c6..6add55d6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -45,7 +45,7 @@ The **Code** button of the header opens the code that loads what the explorer sh ## Load Data -The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. No card: the page uses the whole width, with underlined tabs like the header's, and a section is a title and a sentence on the left, its code on the right (stacked on a narrow screen). The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). +The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. No card, no title (the tab is the title): the page uses the whole width and scrolls by itself (the explorer fills the window, this page does not), with underlined tabs like the header's, and a section is a title and a sentence on the left, its code on the right (stacked on a narrow screen). The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). - **Python SDK**: a whole DataFrame (sent by slices of 100 000 samples, as [PYTHON_SDK.md](PYTHON_SDK.md#timeouts) advises), one sample at a time, a few samples at a time. Same `uv run` header as the Code dialog. - **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write` with a few inputs (see [INFLUX_DB.md](INFLUX_DB.md)), and how to check it (`--once`). diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bda97546..dd4f47a4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -71,7 +71,7 @@ export function Layout() { -
+
diff --git a/frontend/src/lib/loadSnippets.test.ts b/frontend/src/lib/loadSnippets.test.ts index c8d502a9..ab618514 100644 --- a/frontend/src/lib/loadSnippets.test.ts +++ b/frontend/src/lib/loadSnippets.test.ts @@ -27,7 +27,7 @@ describe('load snippets', () => { it('python: three scripts that tell uv what they need, each opening the client', () => { const sections = sectionsFor('python', open); - expect(sections.map((s) => s.title)).toEqual(['A whole DataFrame', 'One sample at a time', 'A few samples at a time']); + expect(sections.map((s) => s.title)).toEqual(['DataFrame', 'One sample at a time', 'Batches']); for (const section of sections) { expect(section.language).toBe('python'); expect(section.code.startsWith('# /// script')).toBe(true); diff --git a/frontend/src/lib/loadSnippets.ts b/frontend/src/lib/loadSnippets.ts index 908d0e57..9588b2f0 100644 --- a/frontend/src/lib/loadSnippets.ts +++ b/frontend/src/lib/loadSnippets.ts @@ -60,9 +60,9 @@ export function pythonSections(input: LoadInput): LoadSection[] { const frame = [ ...pythonHead(input, 'load.py', ['import asyncio', 'from datetime import UTC, datetime, timedelta', ...os, '', 'import polars as pl', 'from sensapp import SensAppClient']), '', - '# A table of samples: a "timestamp" column with a time zone, and a "value" column.', - '# Read yours instead: pl.read_csv("measurements.csv", try_parse_dates=True), pl.read_parquet(...)', - '# or pl.from_pandas(df). A timestamp with no time zone is UTC after .dt.replace_time_zone("UTC").', + '# One row per sample: "timestamp" (with a time zone) and "value".', + '# Or read yours: pl.read_csv("data.csv", try_parse_dates=True), pl.read_parquet(...), pl.from_pandas(df).', + '# A timestamp with no time zone needs .dt.replace_time_zone("UTC").', 'start = datetime.now(UTC) - timedelta(days=30)', 'frame = pl.DataFrame(', ' {', @@ -74,7 +74,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { '', 'async def main() -> None:', ...pythonClient(input, ' '), - ' # A history goes by slices: a request is not meant to carry everything', + ' # Slices keep each request small', ' for part in frame.iter_slices(100_000):', ' await client.publish("temperature", part)', '', @@ -88,7 +88,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { '', 'async def main() -> None:', ...pythonClient(input, ' '), - ' # The sample is stamped with the time it is sent', + ' # The timestamp is the time of the call', ' await client.publish("temperature", 21.5)', '', '', @@ -120,20 +120,20 @@ export function pythonSections(input: LoadInput): LoadSection[] { return [ { - title: 'A whole DataFrame', - description: 'A history, or a file you have: send the table, SensApp keeps its timestamps. Publishing the same name again adds to the same series.', + title: 'DataFrame', + description: 'Send a table of timestamps and values. Publishing the same name again appends to the same series.', language: 'python', code: frame.join('\n') + '\n', }, { title: 'One sample at a time', - description: 'The simplest thing that works, for a sensor that reports now and then.', + description: 'The sample is stamped with the time of the call.', language: 'python', code: one.join('\n') + '\n', }, { - title: 'A few samples at a time', - description: 'A sensor that reports often: keep the samples for a moment and send them together, which costs the server less than a request each.', + title: 'Batches', + description: 'Keep the samples and send ten at a time: fewer requests than one per sample.', language: 'python', code: few.join('\n') + '\n', }, @@ -176,13 +176,13 @@ export function telegrafSections(input: LoadInput): LoadSection[] { { title: 'telegraf.conf', description: - 'Telegraf writes as it would to InfluxDB. A measurement and a field make the name of a series (`cpu` and `usage_idle` give `cpu usage_idle`), the tags become its labels, and `organization` and `bucket` are kept as the labels `influxdb_org` and `influxdb_bucket`.', + 'Telegraf writes through the InfluxDB v2 API. The measurement and the field make the series name (`cpu usage_idle`), the tags become labels, and `organization` and `bucket` are kept as the labels `influxdb_org` and `influxdb_bucket`.', language: 'toml', code: config.join('\n') + '\n', }, { title: 'Run it', - description: 'Check it with one collection, then leave it running.', + description: 'One collection to test, then the daemon.', language: 'bash', code: [ ...(input.authenticated ? [`export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})`] : []), @@ -219,7 +219,7 @@ export function prometheusSections(input: LoadInput): LoadSection[] { ? [ { title: 'A token', - description: 'This server asks for one. Prometheus reads it from a file; this one writes and reads, and is good for a year (the default is an hour).', + description: 'This server asks for a token. Prometheus reads it from a file. This one can read and write, and lasts a year (the default is an hour).', language: 'bash' as const, code: PROMETHEUS_TOKEN_COMMAND + '\n', }, @@ -228,13 +228,13 @@ export function prometheusSections(input: LoadInput): LoadSection[] { { title: 'prometheus.yml', description: - 'Every sample Prometheus scrapes is also sent to SensApp. The labels are kept as they are, and the metric name is the name of the series. Add the remote_read to query SensApp from Prometheus and Grafana too; `read_recent` makes Prometheus ask it for recent data as well, not only for what its own storage has dropped.', + '`remote_write` sends every scraped sample to SensApp, `remote_read` queries it back. Labels are kept, the metric name is the series name. `read_recent` reads recent data from SensApp too, not only what local storage no longer has.', language: 'yaml', code: config.join('\n') + '\n', }, { title: 'Prometheus in a container', - description: 'A container does not reach SensApp at `localhost`: use `host.docker.internal` (Docker Desktop) or the name of the SensApp service in the same network.', + description: 'In a container `localhost` is the container: use `host.docker.internal`, or the name of the SensApp service.', language: 'yaml', code: ['remote_write:', ' - url: http://host.docker.internal:3000/api/v1/prometheus_remote_write', ''].join('\n'), }, @@ -251,25 +251,22 @@ export function curlSections(input: LoadInput): LoadSection[] { const senml = [ ...token, - '# "n" is the name, "u" the unit and "v" the value. "t" is a Unix time in seconds: left out, it is now.', ...request(['--json ' + sh('[{"n":"temperature","u":"Cel","v":21.5},{"n":"humidity","u":"%RH","v":41,"t":1767225600}]')], publish), ]; const csv = [ ...token, - '# A file with a datetime column, a sensor_name column and a value column (and a unit one, if you like)', "printf 'datetime,sensor_name,value,unit\\n2026-01-01T12:00:00Z,temperature,21.5,Cel\\n2026-01-01T12:01:00Z,temperature,21.7,Cel\\n' > measurements.csv", '', ...request(['-H ' + sh('content-type: text/csv'), '--data-binary @measurements.csv'], publish), ]; const line = [ ...token, - '# measurement,tag=value field=value timestamp: the tags become labels. precision=s says the timestamp is in seconds.', ...request(['--data-binary ' + sh('weather,location=oslo temperature=21.5,humidity=41i 1767225600')], write), ]; return [ - { title: 'SenML JSON', description: 'The standard format for sensor data (RFC 8428), and the one that needs the least.', language: 'bash', code: senml.join('\n') + '\n' }, - { title: 'CSV', description: 'A file out of a spreadsheet or a logger. SensApp finds the columns by their names.', language: 'bash', code: csv.join('\n') + '\n' }, - { title: 'InfluxDB line protocol', description: 'What Telegraf and many devices speak. `org` and `bucket` are required, as for InfluxDB.', language: 'bash', code: line.join('\n') + '\n' }, + { title: 'SenML JSON', description: 'RFC 8428. `n` is the name, `u` the unit, `v` the value, `t` a Unix time in seconds (now when missing).', language: 'bash', code: senml.join('\n') + '\n' }, + { title: 'CSV', description: 'The columns are found by name: a datetime, a `sensor_name`, a `value`, and optionally a `unit`.', language: 'bash', code: csv.join('\n') + '\n' }, + { title: 'InfluxDB line protocol', description: '`measurement,tag=value field=value timestamp`. The tags become labels. `org` and `bucket` are required, `precision=s` makes the timestamp seconds.', language: 'bash', code: line.join('\n') + '\n' }, ]; } diff --git a/frontend/src/pages/LoadPage.test.tsx b/frontend/src/pages/LoadPage.test.tsx index 3209a3db..9763b4f1 100644 --- a/frontend/src/pages/LoadPage.test.tsx +++ b/frontend/src/pages/LoadPage.test.tsx @@ -40,7 +40,7 @@ describe('LoadPage', () => { expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument(); await user.click(screen.getByRole('link', { name: 'Load Data' })); - expect(await screen.findByRole('heading', { name: 'Load data into SensApp' })).toBeInTheDocument(); + expect(await screen.findByRole('tablist', { name: 'Way to load data' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Load Data' })).toHaveAttribute('aria-current', 'page'); // The Code button is about the explorer expect(screen.queryByRole('button', { name: 'Code' })).toBeNull(); diff --git a/frontend/src/pages/LoadPage.tsx b/frontend/src/pages/LoadPage.tsx index 2d8143e1..0a667c73 100644 --- a/frontend/src/pages/LoadPage.tsx +++ b/frontend/src/pages/LoadPage.tsx @@ -54,19 +54,7 @@ export function LoadPage() { }); return ( -
-
-

Load data into SensApp

-

- Pick the way that fits your data, copy the code, and run it. The address in it is the one of this server. Everything else SensApp - reads is in the{' '} - - API docs - - . -

-
- +
{LOAD_WAYS.map((item) => ( +
+ + ); +} + +function AuthenticationDisabled() { + return ( + +

+ This server answers everyone, so nothing needs a token. To require tokens, give SensApp a secret with{' '} + SENSAPP_JWT_SECRET (sensapp generate-secret makes one) and remove{' '} + SENSAPP_AUTH_DISABLED. +

+
+ ); +} + +function SensorChoices({ names, chosen, onAdd }: { names: string[]; chosen: string[]; onAdd: (name: string) => void }) { + const offered = names.filter((name) => !chosen.includes(name)).slice(0, SUGGESTED_SENSORS); + if (offered.length === 0) return null; + return ( +
+ {offered.map((name) => ( + + ))} + {names.length > SUGGESTED_SENSORS && and {names.length - SUGGESTED_SENSORS} more} +
+ ); +} + +function CreatedTokenView({ created, onAnother }: { created: CreatedToken; onAnother: () => void }) { + const [copied, setCopied] = useState(null); + + async function handleCopy() { + setCopied(await copyText(created.token)); + window.setTimeout(() => setCopied(null), 2000); + } + + return ( + +
+ This is the only time the token is shown: SensApp does not keep it. Copy it now. +
+ +
+ event.currentTarget.select()} + /> + +
+ +
+
For
+
{created.subject}
+
Scope
+
{created.scope.join(', ')}
+
Sensors
+
{created.sensors?.length ? created.sensors.join(', ') : 'all'}
+
Expires
+
{new Date(created.expires_at * 1000).toLocaleString()}
+
Id
+
{created.jti}
+
+ +

+ Clients take it as Authorization: Bearer. The code of{' '} + + Load Data + {' '} + reads it from SENSAPP_TOKEN: +

+ + +
+ +
+
+ ); +} + +/** `canRead`: the token may read the catalog, which gives the sensors to choose from. An admin token alone cannot. */ +function TokenForm({ canRead }: { canRead: boolean }) { + const metrics = useMetrics(undefined, { enabled: canRead }); + const [subject, setSubject] = useState(''); + const [scope, setScope] = useState(['read', 'write']); + const [sensorsText, setSensorsText] = useState(''); + const [duration, setDuration] = useState(DAY); + + const mutation = useMutation({ + mutationFn: async (body: Parameters[0]['body']) => unwrap(await createToken({ body })), + // The token is not kept in the cache of the mutations once the page is left + gcTime: 0, + }); + + const sensors = parseSensorNames(sensorsText); + const names = (metrics.data?.['dcat:dataset'] ?? []).map((dataset) => dataset['dct:title']); + + if (mutation.isSuccess) { + return mutation.reset()} />; + } + + function toggle(id: string) { + setScope((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id])); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + mutation.mutate({ + subject: subject.trim(), + scope, + sensors: sensors.length > 0 ? sensors : null, + duration_seconds: duration, + }); + } + + return ( + +
+ + +
+ What it may do + {SCOPES.map((item) => ( + + ))} +
+ +
+ + setSensorsText((text) => (text.trim() === '' ? name : `${text.trim().replace(/,$/, '')}, ${name}`))} + /> +
+ + + + {mutation.isError && !(mutation.error instanceof ApiError && mutation.error.isAuthError) && ( +
+ {mutation.error instanceof Error ? mutation.error.message : 'The token was not made'} +
+ )} + +
+ +
+
+
+ ); +} + +/** Where an admin makes the tokens of the clients, and how anyone makes the admin token. */ +export function CredentialsPage() { + const token = useAuthStore((state) => state.token); + // Without a token, the catalog tells whether the server asks for one. With one, it is not + // asked: an admin token cannot read, and the refusal would ask for a token again. + const metrics = useMetrics(undefined, { enabled: token === null }); + const scopes = token === null ? [] : tokenScopes(describeToken(token)); + const isAdmin = scopes.includes('admin'); + + let content; + if (isAdmin) { + content = ; + } else if (token !== null) { + content = ; + } else if (metrics.isPending) { + content = ; + } else if (metrics.isSuccess) { + content = ; + } else { + content = ; + } + + return
{content}
; +} + +export default CredentialsPage; From 3f8d9f46d99aa9bb446f780a5ed171f31311e38b Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 13:46:09 +0200 Subject: [PATCH 40/53] =?UTF-8?q?feat:=20=E2=9C=A8=20auth:=20chart,=20cont?= =?UTF-8?q?ainer,=20CI=20and=20docs=20follow=20the=20secure=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Helm chart makes a random secret on the first install and keeps it across upgrades (auth.jwtSecret, auth.existingSecret, auth.previousSecrets and auth.disabled choose otherwise), and NOTES.txt says how to make an admin token. The demos, the live and perf scripts, the CI smoke job and the quickstart notebook set SENSAPP_AUTH_DISABLED=true explicitly, and a CI step checks that the image refuses to start without authentication. docs/JWT_AUTH.md is rewritten (startup modes, admin scope and endpoint, rotation and revocation, logs); CONFIGURATION, FRONTEND (Credentials tab, token in the link) and the chart README follow. Listing and revoking single tokens is left in ideas/. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 12 ++ Dockerfile | 2 + TODO.md | 2 +- charts/sensapp/README.md | 17 +++ charts/sensapp/templates/NOTES.txt | 23 ++- charts/sensapp/templates/_helpers.tpl | 32 ++++ charts/sensapp/templates/configmap.yaml | 3 + charts/sensapp/templates/deployment.yaml | 11 +- charts/sensapp/templates/secret.yaml | 7 +- charts/sensapp/values.yaml | 25 +++- compose.prometheus-roleplay.yml | 2 + docs/CONFIGURATION.md | 5 +- docs/DATA_LIFECYCLE.md | 4 +- docs/FRONTEND.md | 27 +++- docs/JWT_AUTH.md | 137 ++++++++++++++---- .../secure-by-default-auth.md | 12 +- ideas/token-registry-and-revocation.md | 14 ++ python/quickstart.ipynb | 4 +- tests/perf/dedup.sh | 2 +- tests/perf/scale.sh | 2 +- tests/perf/strings.sh | 2 +- tests/prometheus_live/run.sh | 3 +- 22 files changed, 287 insertions(+), 61 deletions(-) rename {current_tasks => done}/secure-by-default-auth.md (65%) create mode 100644 ideas/token-registry-and-revocation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eade8eb..1f92d71e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,10 +393,22 @@ jobs: cache-from: type=gha,scope=runtime-smoke cache-to: type=gha,scope=runtime-smoke,mode=max + - name: The image refuses to start without authentication + if: matrix.name == 'runtime' + run: | + # No secret and no SENSAPP_AUTH_DISABLED: the image listens on 0.0.0.0, which is not safe open + if docker run --rm sensapp-ci:runtime > refusal.log 2>&1; then + echo "The image started without authentication" + exit 1 + fi + cat refusal.log + grep -q SENSAPP_JWT_SECRET refusal.log + - name: Start runtime image against ClickHouse if: matrix.name == 'runtime' run: >- docker run --detach --name sensapp-ci-runtime --network host + --env SENSAPP_AUTH_DISABLED=true --env SENSAPP_STORAGE_CONNECTION_STRING=clickhouse://default:password@127.0.0.1:8123/sensapp_test sensapp-ci:runtime diff --git a/Dockerfile b/Dockerfile index 72117cd8..56d47bd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,8 @@ RUN ldconfig # Served under /ui/, see SENSAPP_UI_ENABLED COPY --from=frontend /frontend/dist /usr/share/sensapp/ui +# The image listens on every address, so it does not start without authentication: give it +# SENSAPP_JWT_SECRET (`sensapp generate-secret` makes one), or SENSAPP_AUTH_DISABLED=true. ENV SENSAPP_ENDPOINT=0.0.0.0 \ SENSAPP_PORT=3000 \ SENSAPP_UI_DIR=/usr/share/sensapp/ui \ diff --git a/TODO.md b/TODO.md index 5bd95c12..f3a02d9f 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ This file tracks the main remaining work for SensApp. Detailed task history lives in `current_tasks/`, `ideas/` and `done/`. -The core is in place: HTTP-only ingestion, DCAT/query/export endpoints, Prometheus and InfluxDB compatibility, health endpoints, Prometheus service metrics, JWT sensor authorization, HTTP resource limits, data lifecycle controls, cross-series aggregation, a Python SDK, Helm chart and container images, and a broad integration test suite run against every backend in CI. +The core is in place: HTTP-only ingestion, DCAT/query/export endpoints, Prometheus and InfluxDB compatibility, health endpoints, Prometheus service metrics, secure-by-default JWT authentication (sensor authorization, an admin scope, secret rotation, a Credentials tab), HTTP resource limits, data lifecycle controls, cross-series aggregation, a Python SDK, Helm chart and container images, and a broad integration test suite run against every backend in CI. The next phase is not to add more features. It is to make the existing system solid enough for pre-production, with ClickHouse as the reference backend. The acceptance criteria and sequencing are in [docs/PREPRODUCTION_RELEASE_PLAN.md](docs/PREPRODUCTION_RELEASE_PLAN.md). diff --git a/charts/sensapp/README.md b/charts/sensapp/README.md index 5ac61ca3..7d82050c 100644 --- a/charts/sensapp/README.md +++ b/charts/sensapp/README.md @@ -52,3 +52,20 @@ Supported URL forms in the default container image include: | ClickHouse HTTPS | `clickhouses://user:password@clickhouse.example.com:8443/sensapp` | For a quick setup, you can set `storage.connectionString` directly instead of referencing a Secret. Helm stores that value in the release configuration, so use an existing Secret for credentials. External databases own their storage and lifecycle; leave `persistence.enabled=false` for these backends. + +## Authentication + +SensApp does not run open: the container image listens on every address, so it refuses to start without a secret. By default the chart **makes a random secret on the first install** and keeps it across upgrades (it reads it back from the Secret of the release). Make an admin token, which makes the other tokens in the UI (Credentials tab): + +```bash +kubectl exec deploy/sensapp -- sensapp generate-token me --scope admin +``` + +| Value | Effect | +| --- | --- | +| `auth.jwtSecret` | A secret of your choice (at least 32 characters). Stored in the Secret of the release and in the release values: prefer `existingSecret`. | +| `auth.existingSecret`, `auth.existingSecretKey` | A Secret of yours holding `SENSAPP_JWT_SECRET`. Takes precedence over `jwtSecret`. | +| `auth.previousSecrets` | Comma-separated secrets that still verify tokens but never sign: the way to rotate the secret, see [JWT_AUTH.md](../../docs/JWT_AUTH.md#rotating-the-secret-and-revoking-tokens). | +| `auth.disabled` | Every endpoint is open (`SENSAPP_AUTH_DISABLED`), for a network that authenticates in front of SensApp. Ignored when a secret is given. | + +The made secret needs the chart to read the cluster (`lookup`). Tools that only render the chart (`helm template`, Argo CD) cannot, and would make a new secret at each render: for those, set `auth.jwtSecret` or `auth.existingSecret`. diff --git a/charts/sensapp/templates/NOTES.txt b/charts/sensapp/templates/NOTES.txt index b7a927de..6ecccad6 100644 --- a/charts/sensapp/templates/NOTES.txt +++ b/charts/sensapp/templates/NOTES.txt @@ -14,13 +14,22 @@ echo http://127.0.0.1:3000 {{- end }} -{{- if .Values.auth.jwtSecret }} -2. JWT authentication is ENABLED. - Generate a token with: - sensapp generate-token --scope readwrite --duration 3600 +{{- if and .Values.auth.disabled (not .Values.auth.jwtSecret) (not .Values.auth.existingSecret) }} +2. Authentication is DISABLED (auth.disabled): every endpoint is open to whoever can reach it. + To require tokens, remove auth.disabled. +{{- else }} +2. Authentication is ENABLED: the endpoints need a token. +{{- if .Values.auth.existingSecret }} + The secret is the key {{ .Values.auth.existingSecretKey }} of the Secret {{ .Values.auth.existingSecret }}. +{{- else if .Values.auth.jwtSecret }} + The secret is auth.jwtSecret. +{{- else }} + The chart made the secret, and keeps it across upgrades. +{{- end }} + Make an admin token (it makes the other tokens, in the UI under Credentials or with POST /api/v1/admin/tokens): + kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token me --scope admin + Or a token for a client: + kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token --scope readwrite --duration 86400 Use it in requests: curl -H "Authorization: Bearer " http:///metrics -{{- else }} -2. JWT authentication is DISABLED. All endpoints are open. - To enable, set auth.jwtSecret to a random string (≥ 32 chars). {{- end }} diff --git a/charts/sensapp/templates/_helpers.tpl b/charts/sensapp/templates/_helpers.tpl index 06dca6a9..cc7db66e 100644 --- a/charts/sensapp/templates/_helpers.tpl +++ b/charts/sensapp/templates/_helpers.tpl @@ -39,3 +39,35 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- default "default" .Values.serviceAccount.name -}} {{- end -}} {{- end -}} + +{{/* +"true" when the chart makes the JWT secret itself: authentication is on, and no secret was given +(neither `auth.jwtSecret` nor `auth.existingSecret`). Empty otherwise. +*/}} +{{- define "sensapp.generateJwtSecret" -}} +{{- if and (not .Values.auth.disabled) (not .Values.auth.jwtSecret) (not .Values.auth.existingSecret) -}} +true +{{- end -}} +{{- end -}} + +{{/* +The JWT secret the chart makes: the one of the Secret of the release when it exists, so that an +upgrade keeps it (and the tokens made with it), a new random one on the first install. `helm +template` and the tools that only render (Argo CD) cannot look the Secret up: they would make +a new secret at each render, so give `auth.jwtSecret` or `auth.existingSecret` there. +*/}} +{{- define "sensapp.jwtSecret" -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "sensapp.fullname" .) -}} +{{- if and $existing $existing.data (hasKey $existing.data "SENSAPP_JWT_SECRET") -}} +{{- index $existing.data "SENSAPP_JWT_SECRET" | b64dec -}} +{{- else -}} +{{- randAlphaNum 48 -}} +{{- end -}} +{{- end -}} + +{{/* "true" when the Secret of the release holds something */}} +{{- define "sensapp.hasSecret" -}} +{{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.auth.previousSecrets .Values.secretEnv (include "sensapp.generateJwtSecret" .) -}} +true +{{- end -}} +{{- end -}} diff --git a/charts/sensapp/templates/configmap.yaml b/charts/sensapp/templates/configmap.yaml index 91825052..18c600eb 100644 --- a/charts/sensapp/templates/configmap.yaml +++ b/charts/sensapp/templates/configmap.yaml @@ -6,6 +6,9 @@ metadata: {{- include "sensapp.labels" . | nindent 4 }} data: SENSAPP_PORT: {{ .Values.service.port | quote }} + {{- if .Values.auth.disabled }} + SENSAPP_AUTH_DISABLED: "true" + {{- end }} {{- range $key, $value := .Values.env }} {{- if ne $key "SENSAPP_PORT" }} {{ $key }}: {{ $value | quote }} diff --git a/charts/sensapp/templates/deployment.yaml b/charts/sensapp/templates/deployment.yaml index 4583d5ab..9d3282ff 100644 --- a/charts/sensapp/templates/deployment.yaml +++ b/charts/sensapp/templates/deployment.yaml @@ -43,14 +43,14 @@ spec: envFrom: - configMapRef: name: {{ include "sensapp.fullname" . }} - {{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.secretEnv }} + {{- if include "sensapp.hasSecret" . }} - secretRef: name: {{ include "sensapp.fullname" . }} {{- end }} {{- with .Values.extraEnvFrom }} {{- toYaml . | nindent 12 }} {{- end }} - {{- if or .Values.storage.existingSecret .Values.extraEnv }} + {{- if or .Values.storage.existingSecret .Values.auth.existingSecret .Values.extraEnv }} env: {{- if .Values.storage.existingSecret }} - name: SENSAPP_STORAGE_CONNECTION_STRING @@ -59,6 +59,13 @@ spec: name: {{ .Values.storage.existingSecret | quote }} key: {{ .Values.storage.existingSecretKey | quote }} {{- end }} + {{- if .Values.auth.existingSecret }} + - name: SENSAPP_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.auth.existingSecret | quote }} + key: {{ .Values.auth.existingSecretKey | quote }} + {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/sensapp/templates/secret.yaml b/charts/sensapp/templates/secret.yaml index df4606df..7de7554c 100644 --- a/charts/sensapp/templates/secret.yaml +++ b/charts/sensapp/templates/secret.yaml @@ -1,4 +1,4 @@ -{{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.secretEnv }} +{{- if include "sensapp.hasSecret" . }} apiVersion: v1 kind: Secret metadata: @@ -12,6 +12,11 @@ stringData: {{- end }} {{- if .Values.auth.jwtSecret }} SENSAPP_JWT_SECRET: {{ .Values.auth.jwtSecret | quote }} + {{- else if include "sensapp.generateJwtSecret" . }} + SENSAPP_JWT_SECRET: {{ include "sensapp.jwtSecret" . | quote }} + {{- end }} + {{- if .Values.auth.previousSecrets }} + SENSAPP_JWT_PREVIOUS_SECRETS: {{ .Values.auth.previousSecrets | quote }} {{- end }} {{- range $key, $value := .Values.secretEnv }} {{ $key }}: {{ $value | quote }} diff --git a/charts/sensapp/values.yaml b/charts/sensapp/values.yaml index ae4cd1e0..bd04d183 100644 --- a/charts/sensapp/values.yaml +++ b/charts/sensapp/values.yaml @@ -86,14 +86,27 @@ storage: existingSecret: "" existingSecretKey: SENSAPP_STORAGE_CONNECTION_STRING -# Optional JWT authentication. -# When jwtSecret is set, read/write endpoints require a valid Bearer token. -# Health checks, docs, and prometheus scrape remain open. -# Use `sensapp generate-token ` to create tokens. +# Authentication. SensApp does not run open unless told to: the protected endpoints need a JWT +# signed with a secret. Health checks, docs, the UI files and the Prometheus scrape stay open. +# +# By default the chart makes a random secret on the first install and keeps it across upgrades +# (it is read back from the Secret of the release). Make an admin token with: +# kubectl exec deploy/ -- sensapp generate-token me --scope admin +# Tools that only render the chart (Argo CD, `helm template`) cannot read it back and would make a +# new secret each time: for those, give jwtSecret or existingSecret. auth: - # Set to a random string of at least 32 characters to enable JWT auth. - # Leave empty (or omit) to run without authentication (default). + # A secret of at least 32 characters (`sensapp generate-secret` makes one). Stored in the Secret + # of the release, and in the release values: prefer existingSecret. jwtSecret: "" + # A Secret of yours holding the key below. Takes precedence over jwtSecret. + existingSecret: "" + existingSecretKey: SENSAPP_JWT_SECRET + # Secrets that still verify tokens but never sign them, separated by commas: the way to rotate + # the secret. Dropping one revokes the tokens it signed. + previousSecrets: "" + # Run with every endpoint open (SENSAPP_AUTH_DISABLED). For a network that authenticates + # in front of SensApp. Ignored when a secret is given. + disabled: false secretEnv: {} extraEnv: [] diff --git a/compose.prometheus-roleplay.yml b/compose.prometheus-roleplay.yml index 49117fdd..31c7472a 100644 --- a/compose.prometheus-roleplay.yml +++ b/compose.prometheus-roleplay.yml @@ -8,6 +8,8 @@ services: container_name: sensapp-roleplay environment: SENSAPP_ENDPOINT: 0.0.0.0 + # A demo on a laptop, with clients that send no token + SENSAPP_AUTH_DISABLED: "true" SENSAPP_PORT: 3017 SENSAPP_STORAGE_CONNECTION_STRING: duckdb:///workspaces/sensapp/python/roleplay-datascience/roleplay-session.duckdb RUST_LOG: info diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 845adbc0..7c4acd10 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -53,7 +53,10 @@ See [FRONTEND.md](FRONTEND.md). | Variable | Default | Description | | --- | --- | --- | -| `SENSAPP_JWT_SECRET` | unset | Enables JWT authentication when set (at least 32 characters). Unset means every endpoint is open. See [JWT_AUTH.md](JWT_AUTH.md). | +| `SENSAPP_JWT_SECRET` | unset | Secret that signs and verifies the JWTs (at least 32 characters, `sensapp generate-secret` makes one). When unset, SensApp does not run open: on a loopback address it makes a secret for the run and prints an admin token, on any other address (the container image) it refuses to start, unless `SENSAPP_AUTH_DISABLED` is true. See [JWT_AUTH.md](JWT_AUTH.md). | +| `SENSAPP_AUTH_DISABLED` | `false` | Run with every endpoint open: the explicit opt-out for demos, tests, and networks that authenticate in front of SensApp. Ignored when `SENSAPP_JWT_SECRET` is set. | +| `SENSAPP_JWT_PREVIOUS_SECRETS` | unset | Comma-separated secrets that still verify tokens but never sign them: the way to rotate the secret, or revoke the tokens of one. See [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens). | +| `SENSAPP_TOKEN_MAX_DURATION_SECONDS` | `31536000` | Longest validity of a token made with `POST /api/v1/admin/tokens` (a year). The command line is not capped by it. | ### Data diff --git a/docs/DATA_LIFECYCLE.md b/docs/DATA_LIFECYCLE.md index 198697af..63925267 100644 --- a/docs/DATA_LIFECYCLE.md +++ b/docs/DATA_LIFECYCLE.md @@ -80,7 +80,7 @@ No action is needed. ## Authorization -With [JWT authentication](JWT_AUTH.md) enabled, deleting requires the `delete` scope. +With [authentication](JWT_AUTH.md) on, deleting requires the `delete` scope. The default `read write` scope does **not** include it, so a leaked edge-device token cannot erase history. ```bash @@ -92,7 +92,7 @@ A `delete` token is still limited by its `sensors` allow list. Series it cannot as `404`, like on the read endpoints. The `delete` scope does not allow reading: combine it with `read` to be able to list series and check what was removed. -Without JWT authentication, like every other endpoint, deletes are open. +With authentication disabled (`SENSAPP_AUTH_DISABLED`), like every other endpoint, deletes are open. Each deletion is logged at `INFO` level with the series UUID, the token subject, and the number of samples. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6add55d6..31b404a1 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -1,6 +1,6 @@ # Web UI -SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. A second tab, **Load Data**, explains how to get data in. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. +SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. A second tab, **Load Data**, explains how to get data in, and a third, **Credentials**, makes tokens. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. ## Serving @@ -52,9 +52,21 @@ The second tab of the header (`/ui/load`) explains how to get data into SensApp, - **Prometheus**: `remote_write` and `remote_read` (`read_recent: true`), and the address to use from a container. - **curl**: SenML JSON, CSV and InfluxDB line protocol, to `/publish` and `/api/v2/write`. - The server is the origin of the page. When it asked for a token (or one is in use) the code reads it from `SENSAPP_TOKEN` and the page says how to make a `write` one (`sensapp generate-token me --scope write`); **no token is ever written in the code**. Prometheus reads its token from a file, so its tab gives the command that writes it (a year long, for reading and writing). -- **Telegraf and tokens.** Telegraf sends its `token` as `Authorization: Token …`, the InfluxDB way, and SensApp only reads `Bearer …` (it answers `401`). The snippet therefore sets `http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}`, which Telegraf applies after its own header. Checked against a server with `SENSAPP_JWT_SECRET`. +- **Telegraf and tokens.** Telegraf sends its `token` as `Authorization: Token …`, the InfluxDB way, and SensApp reads that scheme as well as `Bearer …`. The snippet gives Telegraf its own `token = "${SENSAPP_TOKEN}"`, no header to rewrite. Checked against a server with a secret. - The snippets are in `src/lib/loadSnippets.ts` (one function for each way, tested), the page in `src/pages/LoadPage.tsx`. The page is loaded when its tab is first opened (5 kB gzipped, the highlighter is shared with the Code dialog). -- All of them were run against a real SensApp (PostgreSQL), with and without a JWT secret: the Python scripts, the curl commands, Telegraf (`--once`), and the Prometheus configuration with `promtool check config`. +- All of them were run against a real SensApp (PostgreSQL), with and without a JWT secret (the Telegraf one before SensApp read the `Token` scheme, with a `Bearer` header): the Python scripts, the curl commands, Telegraf (`--once`), and the Prometheus configuration with `promtool check config`. + +## Credentials + +The third tab (`/ui/credentials`) makes tokens for the clients of SensApp (see [JWT_AUTH.md](JWT_AUTH.md)). What it shows depends on the token in use: + +- **No token, and the server answers without one**: "Authentication is disabled", with how to turn it on. +- **No token, or a token without the `admin` scope**: "An admin token is needed", with the command that makes one (`sensapp generate-token me --scope admin`) and a button that opens the sign-in dialog (which then suggests that command instead of a read token). A local SensApp without a secret prints a token with every scope, admin included, when it starts. +- **An admin token**: a form with the name of the token (it is in the logs of its requests), the scopes (`read` and `write` by default, `delete` on demand, never `admin`), the sensors it is limited to (names separated by commas; the sensors of the server are offered to click when the token in use can read them, which an admin-only token cannot), and the validity (1 hour, 1 day, 30 days, 1 year; a day by default). The server's own cap (`SENSAPP_TOKEN_MAX_DURATION_SECONDS`) and checks answer in the form. + +The token is then **shown once**, with a Copy button, what it was made with and the `export SENSAPP_TOKEN=…` line that the code of [Load Data](#load-data) reads. SensApp keeps nothing: the page has no list of tokens and cannot revoke one (rotating the secret does, see [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens)). The page does not keep the token either: it is dropped from the cache of the requests as soon as the page is left, and a refresh forgets it. + +The page is in `src/pages/CredentialsPage.tsx` and loaded when its tab is first opened (it shows the token as code, with the highlighter). With an admin token the catalog is not read, because an admin token cannot read: the refusal would ask for a token again. ## Address @@ -81,10 +93,11 @@ The list of series is paged by the server (256 per page, cursor based): Previous ## Authentication -With `SENSAPP_JWT_SECRET` unset the UI works without further ado. When it is set, the API answers `401` and the UI shows a dialog asking for a token: paste the output of `sensapp generate-token --scope read`. See [JWT_AUTH.md](JWT_AUTH.md). +SensApp is not open by default (see [JWT_AUTH.md](JWT_AUTH.md)). When authentication is on, the API answers `401` and the UI shows a dialog asking for a token: paste the output of `sensapp generate-token --scope read`. With authentication disabled (`SENSAPP_AUTH_DISABLED`) the UI works without further ado. +- **A local SensApp without a secret prints a link** (`http://127.0.0.1:3000/ui/#token=…`) that opens the UI signed in. The token is in the *fragment* of the address, which the browser never sends to the server, so it is not logged. The UI takes it out of the address as soon as the page loads (`src/lib/tokenFromAddress.ts`), so that it stays out of the history and out of a copied link. - The token is kept in `sessionStorage`: it is gone when the tab closes and never shared with other tabs. -- An expired or invalid token (`401`), or a token without the `read` scope (`403`), shows the dialog again with the answer of the server. +- An expired or invalid token (`401`), or a token without the `read` scope (`403`), shows the dialog again with the answer of the server. So does a refused request to make a token. - "Sign out" in the header forgets the token. - A token with a sensor allow list shows only its sensors, as with any client. @@ -99,7 +112,7 @@ npm run dev # http://localhost:5173/ui/, proxies the API to http://localh npm run lint && npm run typecheck && npm test && npm run build ``` -Run SensApp on port 3000 next to it (`cargo run`). +Run SensApp on port 3000 next to it (`cargo run`: it prints a token and a link on a local run, or start it with `SENSAPP_AUTH_DISABLED=true` to work without one). `src/client` is generated from `openapi.json`, and both are committed. `openapi.json` is the OpenAPI document of the server: a Rust test fails when it is out of date. After an API change: @@ -108,4 +121,4 @@ UPDATE_OPENAPI=1 cargo test frontend_openapi_document # rewrites frontend/open cd frontend && npm run openapi-ts # regenerates src/client ``` -`VITE_SENSAPP_LIVE_URL=http://localhost:3000 npm test` also runs the test that talks to a running SensApp (without authentication). +`VITE_SENSAPP_LIVE_URL=http://localhost:3000 npm test` also runs the test that talks to a running SensApp (without authentication: start it with `SENSAPP_AUTH_DISABLED=true`). diff --git a/docs/JWT_AUTH.md b/docs/JWT_AUTH.md index 1b98662e..f0d32be8 100644 --- a/docs/JWT_AUTH.md +++ b/docs/JWT_AUTH.md @@ -1,20 +1,40 @@ -# JWT Authentication +# Authentication -SensApp supports **optional** JWT (JSON Web Token) authentication. By default, all endpoints are open — just like Prometheus. When enabled, JWT tokens control read, write and delete access, and can optionally restrict which sensors a client may interact with. +SensApp is **not open unless you ask for it**. The protected endpoints need a JWT (JSON Web Token) signed with a secret that only SensApp and its operator know. A token has a scope (read, write, delete, admin) and can optionally restrict which sensors a client may interact with. -## Enabling Authentication +Tokens are stateless: SensApp signs them and checks the signature, and keeps nothing. That keeps several instances behind a load balancer in agreement with no shared state, other than the secret. -Set the `SENSAPP_JWT_SECRET` environment variable (or `jwt_secret` in `settings.toml`) to a secret string of **at least 32 characters**: +## Starting SensApp + +What happens at startup depends on the secret and on the address SensApp listens on (`SENSAPP_ENDPOINT`, `127.0.0.1` by default; the container image uses `0.0.0.0`): + +| `SENSAPP_JWT_SECRET` | `SENSAPP_AUTH_DISABLED` | Listens on | Result | +|---|---|---|---| +| set | any | any | Authentication on. | +| unset | `true` | any | **Open**: every endpoint answers anyone, with a warning. The explicit opt-out. | +| unset | unset | a loopback address (`127.0.0.1`, `::1`) | Authentication on with a **secret made for this run**. SensApp prints a token (all scopes, 24 hours) and a link to the UI that signs in with it. | +| unset | unset | any other address | **SensApp does not start** and says how to fix it. | + +The last row is the point: a container or a server has no business answering everyone because nobody configured it. The secret made on a loopback address only lives in the process, so a restart refuses the tokens made before; it is for a developer's machine, where only the local user can connect. Several instances behind a load balancer need the same configured secret, which is why they never get one made for them. ```bash -export SENSAPP_JWT_SECRET="my-super-secret-key-at-least-32-characters-long" +# A local run: SensApp prints a token and a link +cargo run + +# A secret that stays, for anything else +export SENSAPP_JWT_SECRET=$(sensapp generate-secret) + +# Open, for a demo, a test, or a network that authenticates in front of SensApp +export SENSAPP_AUTH_DISABLED=true ``` -When unset, authentication is disabled and all endpoints remain open. +The secret must be at least **32 characters**. `sensapp generate-secret` prints a random one of 48 characters (288 bits). Keep it private: anyone with the secret can make any token. In `settings.toml` it is `jwt_secret`, but an environment variable or a Secret is a better place than a file. -## Generating Tokens +## Making tokens -Use the built-in CLI command to generate signed tokens: +### With the command line + +The command needs the secret (`SENSAPP_JWT_SECRET`), so it runs where SensApp runs: ```bash # Read + write access, 1 hour validity (default) @@ -26,27 +46,44 @@ sensapp generate-token prometheus-scraper --scope read --duration 86400 # Write-only, restricted to specific sensors sensapp generate-token edge-device --scope write --sensors "temperature,humidity,pressure" -# Read + write (explicit) -sensapp generate-token admin --scope readwrite --duration 3600 - # Permission to delete series and samples, never granted by default sensapp generate-token cleanup --scope delete --duration 900 # Scopes can be combined -sensapp generate-token admin --scope readwrite,delete -``` +sensapp generate-token cleanup --scope readwrite,delete -The token is printed to stdout. +# A token that makes tokens +sensapp generate-token me --scope admin +``` -### Options +The token is printed to stdout. In a container: `docker exec sensapp generate-token …`, in Kubernetes `kubectl exec deploy/ -- sensapp generate-token …`. | Flag | Description | Default | |------|-------------|---------| -| `--scope` | Comma-separated `read`, `write`, `delete`, or `readwrite` (shorthand for `read,write`) | `read write` (no `delete`) | -| `--duration` | Token validity in seconds | `3600` (1 hour) | +| `--scope` | Comma-separated `read`, `write`, `delete`, `admin`, or `readwrite` (shorthand for `read,write`) | `read write` | +| `--duration` | Token validity in seconds, at most ten years | `3600` (1 hour) | | `--sensors` | Comma-separated sensor name allow list | all sensors | -## Using Tokens +### With an admin token + +A token with the `admin` scope makes other tokens, through the Credentials tab of the [web UI](FRONTEND.md#credentials) or `POST /api/v1/admin/tokens`: + +```bash +curl -X POST http://localhost:3000/api/v1/admin/tokens \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"subject": "edge-device-7", "scope": ["write"], "sensors": ["temperature"], "duration_seconds": 86400}' +``` + +The answer holds the `token` and what it was made with (`subject`, `scope`, `sensors`, `expires_at`, `jti`). **The token is only shown there**: SensApp keeps nothing, so a token cannot be listed or looked up later. The answer carries `Cache-Control: no-store`. + +- `scope` is a list of `read`, `write` and `delete`. **The endpoint never makes an `admin` token** (403): those come from the command line, which has the secret. A stolen admin token therefore cannot renew itself, and an admin token that expires is replaced with one command. +- `duration_seconds` is at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (one year by default). +- An admin token reads and writes nothing: the scope is as separate as `delete` is. Whoever has it can still make a token for any scope, so keep it as private as the secret, and short. It lasts an hour by default. +- With authentication disabled there is nothing to sign with: the answer is `404`. +- Every creation is logged (who asked, for whom, the scope, the sensors, the expiry, the `jti`), never the token. + +## Using tokens Include the token in the `Authorization` header: @@ -54,7 +91,7 @@ Include the token in the `Authorization` header: Authorization: Bearer ``` -Example with curl: +`Authorization: Token ` is accepted too: it is how the InfluxDB clients (Telegraf's `outputs.influxdb_v2` `token`) send theirs. ```bash # Write data with a write token @@ -68,6 +105,17 @@ curl http://localhost:3000/metrics \ -H "Authorization: Bearer $TOKEN" ``` +## Scopes + +| Scope | Allows | Default | +|-------|--------|---------| +| `read` | Reading series, samples and queries | yes | +| `write` | Sending samples | yes | +| `delete` | Deleting series and samples, and the maintenance (`vacuum`) | no | +| `admin` | Making tokens (`POST /api/v1/admin/tokens`) | no | + +None implies another. A token without a `scope` claim has `read write`. + ## Route Protection | Endpoint | Auth Required | Scope | @@ -89,12 +137,13 @@ curl http://localhost:3000/metrics \ | `POST /api/v2/write` | Yes | `write` | | `POST /api/v1/prometheus_remote_write` | Yes | `write` | | `POST /api/v1/admin/vacuum` | Yes | `delete` (it removes duplicate samples) | +| `POST /api/v1/admin/tokens` | Yes | `admin` | Health checks, documentation, the web UI files, and Prometheus scrape endpoints are always public so that orchestration tools (Kubernetes probes, Prometheus scraper) work without tokens. ## JWT Claims -Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: +Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims, and a `kid` in the header that names the secret that signed them: ```json { @@ -102,6 +151,9 @@ Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: "exp": 1700003600, "iat": 1700000000, "nbf": 1700000000, + "iss": "sensapp", + "aud": "sensapp", + "jti": "6f9c2f5e-4b1e-4c52-9a43-2f1c8f0f3a11", "scope": "read write", "sensors": ["temperature", "humidity"] } @@ -111,28 +163,59 @@ Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: |-------|----------|-------------| | `sub` | Yes | Subject — identifies who/what the token was issued to | | `exp` | Yes | Expiration time (Unix timestamp). Tokens are rejected after this time | +| `iss` | Yes | Issuer, `sensapp`. A token of another issuer is rejected, even signed with the right secret | +| `aud` | Yes | Audience, `sensapp`. Same | | `iat` | No | Issued-at time (informational) | | `nbf` | No | Not-before time. If present, the token is rejected before this time | -| `scope` | No | Space-separated among `"read"`, `"write"` and `"delete"`. Defaults to `"read write"`, which does not include `"delete"` | +| `jti` | No | Unique id of the token, in the logs of its requests. Every token SensApp makes has one | +| `scope` | No | Space-separated among `"read"`, `"write"`, `"delete"` and `"admin"`. Defaults to `"read write"` | | `sensors` | No | Array of allowed sensor names. If absent, all sensors are accessible | +A token made by hand (with a JWT library) needs `iss` and `aud` set to `sensapp`; without a `kid` it is checked against the current secret. + ### Time Validation - **`exp`** is always required and validated. Expired tokens are rejected with `401 Unauthorized`. - **`nbf`** is validated when present. Tokens presented before their not-before time are rejected. - The `jsonwebtoken` library applies a default leeway of 60 seconds for clock skew tolerance. +## Rotating the secret, and revoking tokens + +SensApp keeps no list of tokens, so a token cannot be revoked one by one: it works until it expires. What can be revoked is **a secret**, and with it every token it signed. That is also how the secret is changed without cutting the clients off at once: + +1. Generate a new secret (`sensapp generate-secret`). +2. Put it in `SENSAPP_JWT_SECRET`, and move the old one to `SENSAPP_JWT_PREVIOUS_SECRETS` (comma-separated, several are allowed). Restart the instances. New tokens are signed with the new secret; the tokens of the old one still verify. +3. Once those tokens have expired or the clients got new ones, remove the old secret from `SENSAPP_JWT_PREVIOUS_SECRETS`. + +To **revoke at once** (a leaked token, a lost admin token), do step 2 and leave the old secret out: every token it signed is refused. That includes the ones of the clients you trust, which need new tokens. The `kid` of a token tells which secret signed it, and a token whose `kid` is unknown is refused. Short durations are the other defence: prefer a day to a year, and an hour for an admin. + +Listing and revoking single tokens is a possible future feature (`ideas/token-registry-and-revocation.md`). + +## Logs + +The `subject` and the `token_id` (the `jti`) of an authenticated request are part of its log line, so the logs tell who did what: + +``` +INFO request{method=POST uri=/api/v2/write … subject="telegraf" token_id="0ff625ed-…"}: … +``` + +The token itself is never logged, and neither is the `Authorization` header. + ## Security Notes -- The JWT secret must be kept private — anyone with the secret can create valid tokens. -- Use a cryptographically random secret of at least 32 characters. -- For production deployments, consider short-lived tokens and rotate secrets periodically. +- The secret must be kept private — anyone with the secret can create valid tokens, an admin token included. Use `sensapp generate-secret`, or any cryptographically random string of at least 32 characters. +- Prefer short-lived tokens, rotate the secret now and then, and keep the admin token short. +- Run SensApp behind TLS when it is reachable over a network: a token is a bearer credential. - The sensor allow list uses exact string matching on sensor names. - When present, the sensor allow list applies to all API reads and writes, including Prometheus and InfluxDB compatibility endpoints. Catalog and selector results omit other sensors; direct series requests for another sensor return 404, and writes to another sensor return 403. -- With JWT authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. +- With authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. - Sensor-scoped tokens cannot run the database-wide `/api/v1/admin/vacuum` operation. -- The `delete` scope is never implied by `read write`. It only allows deleting, not reading. With a sensor allow list, series the token cannot access are reported as not found. See [DATA_LIFECYCLE.md](DATA_LIFECYCLE.md). +- The `delete` and `admin` scopes are never implied by `read write`. `delete` only allows deleting, not reading. With a sensor allow list, series the token cannot access are reported as not found. See [DATA_LIFECYCLE.md](DATA_LIFECYCLE.md). ## Web UI -The UI (see [FRONTEND.md](FRONTEND.md)) is made of public static files. When authentication is enabled, its API calls get `401`, and it asks for a token: paste the output of `sensapp generate-token`. The UI keeps the token for the browser tab only and sends it as `Authorization: Bearer`. A `read` token is enough to explore; a token without `read` gets `403`, which the UI reports the same way. +The UI (see [FRONTEND.md](FRONTEND.md)) is made of public static files. When authentication is on, its API calls get `401`, and it asks for a token: paste the output of `sensapp generate-token`, or open the link a local SensApp prints, which signs in by itself. The UI keeps the token for the browser tab only and sends it as `Authorization: Bearer`. A `read` token is enough to explore; a token without `read` gets `403`, which the UI reports the same way. The **Credentials** tab makes tokens for an admin token. + +## Kubernetes + +The [Helm chart](../charts/sensapp/README.md) makes a random secret on the first install and keeps it across upgrades; give `auth.jwtSecret` or `auth.existingSecret` to choose it, `auth.previousSecrets` to rotate it, and `auth.disabled=true` to run open. diff --git a/current_tasks/secure-by-default-auth.md b/done/secure-by-default-auth.md similarity index 65% rename from current_tasks/secure-by-default-auth.md rename to done/secure-by-default-auth.md index 08ed8ca8..05d0777d 100644 --- a/current_tasks/secure-by-default-auth.md +++ b/done/secure-by-default-auth.md @@ -18,7 +18,16 @@ Until now authentication was opt-in (`SENSAPP_JWT_SECRET`), tokens came only fro 2. Token model: `admin` scope, `jti`, `iss`/`aud`, `kid` and previous secrets, `Token` scheme, subject in the logs, shared token request validation. 3. `POST /api/v1/admin/tokens`, OpenAPI and the regenerated client. 4. Frontend: Credentials tab, `#token=` pickup, the Telegraf header workaround removed. -5. Helm, Docker, compose, scripts and docs. +5. Helm (a made secret, `existingSecret`, `previousSecrets`, `disabled`), the container, the compose file, the perf and live scripts and the notebook (explicit `SENSAPP_AUTH_DISABLED`), a CI step that the image refuses to start open, and the docs. + +## Result + +Done on the branch `frontend-good-enough`, in five commits. See `docs/JWT_AUTH.md` for the behaviour. What was verified, and what was not: + +- Verified: the full Rust suite on SQLite and PostgreSQL (clippy and fmt clean), the frontend (typecheck, lint, 243 tests, `npm audit`), the real binary (the three startup modes, `Token` scheme, the subject in the logs), the UI in the browser pane against a local SensApp (the link signs in, the Credentials tab makes a token that is limited as asked), `helm lint` and `helm template` with the four auth setups. +- Not verified: the Helm `lookup` that keeps the made secret across upgrades (no cluster here), the new CI step that checks the image refuses to start (it only runs in CI; its command was replayed with the binary), and the other backends (TimescaleDB, DuckDB, ClickHouse), which the tests of this task do not depend on but CI runs. +- Left out on purpose: listing and revoking single tokens, in `ideas/token-registry-and-revocation.md`. +- The README (human-owned) still says authentication is optional and that all endpoints are open by default: it needs the wording proposed in the session. ## Progress @@ -27,3 +36,4 @@ Until now authentication was opt-in (`SENSAPP_JWT_SECRET`), tokens came only fro - Found: `http::server::tests::frontend_openapi_document_is_up_to_date` already failed before this task (the saved `frontend/openapi.json` is stale); step 3 regenerates it. - Step 3 done: `POST /api/v1/admin/tokens` (admin scope; refuses `admin` tokens; duration capped by `SENSAPP_TOKEN_MAX_DURATION_SECONDS`, one year by default, held by `AuthConfig`; `Cache-Control: no-store`; 404 when authentication is disabled; audit log line without the token). OpenAPI and the frontend client regenerated. - Step 4 done: Credentials tab (`frontend/src/pages/CredentialsPage.tsx`: open-server and not-admin states, the form, the token shown once), `#token=` pickup (`lib/tokenFromAddress.ts`), refused mutations ask for a token like refused queries, the Telegraf snippet uses its own `token`. Checked in the browser pane against a local SensApp: the printed link signs in and drops the fragment, the made token only sees its sensors and cannot write others nor make tokens. `ideas/influxdb-token-authorization-scheme.md` moved to `done/`. +- Step 5 done: chart, container, CI, scripts, notebook, docs (`JWT_AUTH.md` rewritten, `CONFIGURATION.md`, `FRONTEND.md`, chart README, `DATA_LIFECYCLE.md`), `TODO.md`. diff --git a/ideas/token-registry-and-revocation.md b/ideas/token-registry-and-revocation.md new file mode 100644 index 00000000..4c38d534 --- /dev/null +++ b/ideas/token-registry-and-revocation.md @@ -0,0 +1,14 @@ +# Token registry: list and revoke single tokens + +The authentication work of October 2026 kept tokens **stateless** on purpose: SensApp signs them and keeps nothing. The price is that a token cannot be listed, and cannot be revoked one by one: only rotating the secret (`SENSAPP_JWT_PREVIOUS_SECRETS`) revokes, and it revokes every token the secret signed. The Credentials tab can only make tokens. + +The option that was left out, if one-by-one management is wanted later: + +- Every token already carries a `jti` (and the logs have it), so the hook exists. +- A `credentials` table in the main storage: `jti`, subject, scope, sensors, `created_at`, `expires_at`, creator (the `jti` of the admin token that asked), `revoked_at`. `POST /api/v1/admin/tokens` inserts a row; `GET /api/v1/admin/tokens` lists them; `DELETE /api/v1/admin/tokens/{jti}` sets `revoked_at`. +- Checking a request must not cost a query: each instance keeps the set of revoked `jti` in memory and refreshes it every few seconds (a bounded delay, safe with several instances, which a per-process list would not be). Tokens of the command line have a `jti` too and are not registered: they are valid unless their `jti` is revoked, which works as a denylist. +- Backends: the SQL ones (PostgreSQL, TimescaleDB, SQLite, DuckDB) and ClickHouse (insert-only events, a `ReplacingMergeTree` or a revocations table) can hold it; BigQuery and rrdcached cannot (or should not), so the Credentials tab would fall back to create-only there, as now. +- Concurrent writers: revoking is idempotent and creating has a fresh UUID, so two instances cannot conflict; test it anyway with two routers on one database. +- UI: a table in the Credentials tab (name, scope, sensors, expiry, creator, a Revoke button). + +Decide when someone needs to revoke a single client without cutting the others off. diff --git a/python/quickstart.ipynb b/python/quickstart.ipynb index 5c5b12ed..b8986e18 100644 --- a/python/quickstart.ipynb +++ b/python/quickstart.ipynb @@ -9,10 +9,10 @@ "\n", "Store a small temperature dataset, fetch it with Prometheus-style label filters, and plot it. Each run uses a new `run` label, so rerunning the notebook keeps earlier data.\n", "\n", - "From the repository root, start SensApp in one terminal:\n", + "From the repository root, start SensApp in one terminal (without authentication, as this notebook sends no token; see `docs/JWT_AUTH.md` to use one):\n", "\n", "```bash\n", - "SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db cargo run\n", + "SENSAPP_AUTH_DISABLED=true SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db cargo run\n", "```\n", "\n", "In another terminal, install the notebook dependencies with [uv](https://docs.astral.sh/uv/) and open it:\n", diff --git a/tests/perf/dedup.sh b/tests/perf/dedup.sh index 557c622d..bc91c631 100755 --- a/tests/perf/dedup.sh +++ b/tests/perf/dedup.sh @@ -18,7 +18,7 @@ WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT # shellcheck disable=SC2086 -env SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +env SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=600 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 ${EXTRA_ENV:-} \ "$BIN" > "$WORK/server.log" 2>&1 & PID=$! diff --git a/tests/perf/scale.sh b/tests/perf/scale.sh index 117fa6c6..f05de10f 100755 --- a/tests/perf/scale.sh +++ b/tests/perf/scale.sh @@ -15,7 +15,7 @@ PORT=${PORT:-3980} WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT -SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=300 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 "$BIN" > "$WORK/server.log" 2>&1 & PID=$! for _ in $(seq 1 60); do curl -sf "localhost:$PORT/health/ready" >/dev/null && break; sleep 0.5; done diff --git a/tests/perf/strings.sh b/tests/perf/strings.sh index 13b78eed..4faec258 100755 --- a/tests/perf/strings.sh +++ b/tests/perf/strings.sh @@ -16,7 +16,7 @@ PORT=${PORT:-3981} WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT -SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=300 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 "$BIN" > "$WORK/server.log" 2>&1 & PID=$! for _ in $(seq 1 60); do curl -sf "localhost:$PORT/health/ready" >/dev/null && break; sleep 0.5; done diff --git a/tests/prometheus_live/run.sh b/tests/prometheus_live/run.sh index 41f7930c..ea71f9f6 100644 --- a/tests/prometheus_live/run.sh +++ b/tests/prometheus_live/run.sh @@ -30,7 +30,8 @@ trap cleanup EXIT cd "$repo_root" cargo build --locked --no-default-features --features postgres -SENSAPP_ENDPOINT=0.0.0.0 SENSAPP_PORT=3017 \ +# The test clients send no token: run open, explicitly +SENSAPP_AUTH_DISABLED=true SENSAPP_ENDPOINT=0.0.0.0 SENSAPP_PORT=3017 \ "$repo_root/target/debug/sensapp" >"$work_dir/sensapp.log" 2>&1 & sensapp_pid=$! From 244feb3c935409e0bf2ce0f59fe20aa7bb4031f6 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:01:02 +0200 Subject: [PATCH 41/53] =?UTF-8?q?feat:=20=E2=9C=A8=20auth:=20sensor=20name?= =?UTF-8?q?s=20with=20commas,=20and=20the=20scopes=20in=20the=20OpenAPI=20?= =?UTF-8?q?document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sensor names of a token are kept exactly as given (they were trimmed), so a name with a comma or a space around it works through the endpoint, and the new repeatable `sensapp generate-token --sensor ` takes one name as it is. `--sensors` still splits on commas. The OpenAPI document declares the bearer token as a security scheme, and each protected operation names its scope (read, write, delete, admin); /prometheus/metrics is public and takes a read token for the latest samples. A test keeps the declarations in step with the routes. The frontend client is regenerated. Co-Authored-By: Claude Sonnet 5.5 --- docs/JWT_AUTH.md | 10 ++- frontend/openapi.json | 114 +++++++++++++++++++++++++++++++ frontend/src/client/sdk.gen.ts | 75 ++++++++++++++++---- src/http/auth.rs | 34 +++++++-- src/http/crud.rs | 7 ++ src/http/influxdb.rs | 1 + src/http/metrics.rs | 2 + src/http/prometheus_read.rs | 1 + src/http/prometheus_write.rs | 1 + src/http/server.rs | 93 +++++++++++++++++++++++++ src/http/simple_promql.rs | 1 + src/main.rs | 20 ++++-- tests/integration/real_router.rs | 5 +- 13 files changed, 339 insertions(+), 25 deletions(-) diff --git a/docs/JWT_AUTH.md b/docs/JWT_AUTH.md index f0d32be8..20a8b513 100644 --- a/docs/JWT_AUTH.md +++ b/docs/JWT_AUTH.md @@ -46,6 +46,9 @@ sensapp generate-token prometheus-scraper --scope read --duration 86400 # Write-only, restricted to specific sensors sensapp generate-token edge-device --scope write --sensors "temperature,humidity,pressure" +# A name with a comma in it: one --sensor for each name, kept as it is +sensapp generate-token edge-device --scope write --sensor "cpu,usage" --sensor "mem used" + # Permission to delete series and samples, never granted by default sensapp generate-token cleanup --scope delete --duration 900 @@ -63,6 +66,7 @@ The token is printed to stdout. In a container: `docker exec sensapp | `--scope` | Comma-separated `read`, `write`, `delete`, `admin`, or `readwrite` (shorthand for `read,write`) | `read write` | | `--duration` | Token validity in seconds, at most ten years | `3600` (1 hour) | | `--sensors` | Comma-separated sensor name allow list | all sensors | +| `--sensor` | One sensor name, as it is (a comma is part of the name); repeat it for several. Can be mixed with `--sensors` | all sensors | ### With an admin token @@ -201,12 +205,16 @@ INFO request{method=POST uri=/api/v2/write … subject="telegraf" token_id="0ff6 The token itself is never logged, and neither is the `Authorization` header. +## API documentation + +The OpenAPI document (`/docs`) declares the bearer token as a security scheme and each protected operation names the scope it needs (`read`, `write`, `delete` or `admin`), `POST /api/v1/admin/tokens` included. `/prometheus/metrics` is public, and takes a `read` token for `include_latest_samples=true`. + ## Security Notes - The secret must be kept private — anyone with the secret can create valid tokens, an admin token included. Use `sensapp generate-secret`, or any cryptographically random string of at least 32 characters. - Prefer short-lived tokens, rotate the secret now and then, and keep the admin token short. - Run SensApp behind TLS when it is reachable over a network: a token is a bearer credential. -- The sensor allow list uses exact string matching on sensor names. +- The sensor allow list uses exact string matching on sensor names, spaces and commas included: the names are kept exactly as given. The JSON array of the endpoint and `--sensor` can hold a name with a comma, `--sensors` splits on commas. - When present, the sensor allow list applies to all API reads and writes, including Prometheus and InfluxDB compatibility endpoints. Catalog and selector results omit other sensors; direct series requests for another sensor return 404, and writes to another sensor return 403. - With authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. - Sensor-scoped tokens cannot run the database-wide `/api/v1/admin/vacuum` operation. diff --git a/frontend/openapi.json b/frontend/openapi.json index 1f252fbc..dce8a452 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -223,6 +223,14 @@ ], "type": "object" } + }, + "securitySchemes": { + "bearer": { + "bearerFormat": "JWT", + "description": "A JWT signed with the secret of the server, sent as `Authorization: Bearer ` (`Authorization: Token `, which InfluxDB clients send, works too). Each operation lists the scope it needs: `read`, `write`, `delete` or `admin`. Make one with `sensapp generate-token`, or with `POST /api/v1/admin/tokens` and an `admin` token (the Credentials tab of the web UI does). With authentication disabled (`SENSAPP_AUTH_DISABLED`) no token is needed.", + "scheme": "bearer", + "type": "http" + } } }, "info": { @@ -294,6 +302,13 @@ "description": "Authentication is disabled: there is nothing to sign a token with" } }, + "security": [ + { + "bearer": [ + "admin" + ] + } + ], "summary": "Create a token", "tags": [ "Admin" @@ -335,6 +350,13 @@ "description": "The request took longer than SENSAPP_HTTP_MAINTENANCE_TIMEOUT_SECONDS (one hour by default), and the storage backend may carry on after the answer" } }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], "summary": "Database Vacuuming", "tags": [ "Admin" @@ -417,6 +439,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "Prometheus Remote Read API.", "tags": [ "Prometheus" @@ -506,6 +535,13 @@ "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" } }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], "summary": "Prometheus Remote Write API.", "tags": [ "Prometheus" @@ -567,6 +603,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "Simple PromQL query endpoint.", "tags": [ "SensApp" @@ -670,6 +713,13 @@ "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" } }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], "summary": "InfluxDB Compatible Write API.", "tags": [ "InfluxDB" @@ -778,6 +828,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "List unique metrics (measurement types) with aggregated information in DCAT catalog format.", "tags": [ "SensApp" @@ -828,6 +885,14 @@ "description": "Prometheus-compatible metrics" } }, + "security": [ + {}, + { + "bearer": [ + "read" + ] + } + ], "tags": [ "Observability" ] @@ -896,6 +961,13 @@ "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" } }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], "summary": "SensApp native data ingestion API supporting multiple formats.", "tags": [ "SensApp" @@ -960,6 +1032,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "List all series (time series) in DCAT catalog format.", "tags": [ "SensApp" @@ -1004,6 +1083,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], "summary": "Delete a series: all its samples, its labels and the sensor itself.", "tags": [ "Admin" @@ -1127,6 +1213,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "Get series data in various formats based on query parameter.", "tags": [ "SensApp" @@ -1196,6 +1289,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "Get presence and optional bucket coverage for a series over a time window.", "tags": [ "SensApp" @@ -1256,6 +1356,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], "summary": "Get the most recent sample for a series, optionally within a bounded time window.", "tags": [ "SensApp" @@ -1323,6 +1430,13 @@ "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" } }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], "summary": "Delete the samples of a series between `start` and `end`, both inclusive.", "tags": [ "Admin" diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 03d2850d..bb528e5c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -30,6 +30,7 @@ export const frontpage = (options?: Option * `sensapp generate-token`, which needs the secret. Not available when authentication is disabled. */ export const createToken = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/api/v1/admin/tokens', ...options, headers: { @@ -46,7 +47,11 @@ export const createToken = (options: Optio * the storage backend supports it. Only exact duplicates go: two different values at the same * timestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list. */ -export const vacuumDatabase = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/v1/admin/vacuum', ...options }); +export const vacuumDatabase = (options?: Options): RequestResult => (options?.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/admin/vacuum', + ...options +}); /** * Prometheus Remote Read API. @@ -55,7 +60,11 @@ export const vacuumDatabase = (options?: O * * It follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/). */ -export const prometheusRemoteRead = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/prometheus_remote_read', ...options }); +export const prometheusRemoteRead = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/prometheus_remote_read', + ...options +}); /** * Prometheus Remote Write API. @@ -64,7 +73,11 @@ export const prometheusRemoteRead = (optio * * It follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/). */ -export const publishPrometheus = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/prometheus_remote_write', ...options }); +export const publishPrometheus = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/prometheus_remote_write', + ...options +}); /** * Simple PromQL query endpoint. @@ -79,7 +92,11 @@ export const publishPrometheus = (options: * - Range query: `GET /api/v1/query?query=my_metric[5m]` * - With format: `GET /api/v1/query?query=my_metric&format=csv` */ -export const simplePromqlQuery = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/query', ...options }); +export const simplePromqlQuery = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/query', + ...options +}); /** * InfluxDB Compatible Write API. @@ -89,6 +106,7 @@ export const simplePromqlQuery = (options: */ export const publishInfluxdb = (options: Options): RequestResult => (options.client ?? client).post({ bodySerializer: null, + security: [{ scheme: 'bearer', type: 'http' }], url: '/api/v2/write', ...options, headers: { @@ -116,9 +134,17 @@ export const readiness = (options?: Option /** * List unique metrics (measurement types) with aggregated information in DCAT catalog format. */ -export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/metrics', ...options }); +export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/metrics', + ...options +}); -export const prometheusMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/prometheus/metrics', ...options }); +export const prometheusMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/prometheus/metrics', + ...options +}); /** * SensApp native data ingestion API supporting multiple formats. @@ -132,6 +158,7 @@ export const prometheusMetrics = (options? */ export const publishSensorsData = (options: Options): RequestResult => (options.client ?? client).post({ bodySerializer: null, + security: [{ scheme: 'bearer', type: 'http' }], url: '/publish', ...options, headers: { @@ -143,7 +170,11 @@ export const publishSensorsData = (options /** * List all series (time series) in DCAT catalog format. */ -export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/series', ...options }); +export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series', + ...options +}); /** * Delete a series: all its samples, its labels and the sensor itself. @@ -151,22 +182,38 @@ export const listSeries = (options?: Optio * Publishing the same sensor again recreates it with the same UUID. * Requires the `delete` scope when authentication is enabled. */ -export const deleteSeries = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/series/{series_uuid}', ...options }); +export const deleteSeries = (options: Options): RequestResult => (options.client ?? client).delete({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}', + ...options +}); /** * Get series data in various formats based on query parameter. */ -export const getSeriesData = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}', ...options }); +export const getSeriesData = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}', + ...options +}); /** * Get presence and optional bucket coverage for a series over a time window. */ -export const getSeriesAvailability = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}/availability', ...options }); +export const getSeriesAvailability = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/availability', + ...options +}); /** * Get the most recent sample for a series, optionally within a bounded time window. */ -export const getSeriesLastSample = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}/last', ...options }); +export const getSeriesLastSample = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/last', + ...options +}); /** * Delete the samples of a series between `start` and `end`, both inclusive. @@ -176,4 +223,8 @@ export const getSeriesLastSample = (option * deletes the samples at one exact timestamp. The sensor and its labels are kept. * Requires the `delete` scope when authentication is enabled. */ -export const deleteSeriesSamples = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/series/{series_uuid}/samples', ...options }); +export const deleteSeriesSamples = (options: Options): RequestResult => (options.client ?? client).delete({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/samples', + ...options +}); diff --git a/src/http/auth.rs b/src/http/auth.rs index 11a1ec83..9a0f58b1 100644 --- a/src/http/auth.rs +++ b/src/http/auth.rs @@ -398,10 +398,11 @@ impl TokenRequest { if names.len() > MAX_SENSORS { return Err(invalid(format!("More than {MAX_SENSORS} sensors"))); } + // A name is kept exactly as given, spaces and commas included: it is matched + // exactly against the names of the sensors let mut cleaned: Vec = Vec::with_capacity(names.len()); for name in names { - let name = name.trim(); - if name.is_empty() { + if name.trim().is_empty() { return Err(invalid("A sensor name is empty".into())); } if name.chars().count() > MAX_SENSOR_NAME_LENGTH { @@ -409,8 +410,8 @@ impl TokenRequest { "A sensor name is longer than {MAX_SENSOR_NAME_LENGTH} characters" ))); } - if !cleaned.iter().any(|known| known == name) { - cleaned.push(name.to_string()); + if !cleaned.contains(&name) { + cleaned.push(name); } } Some(cleaned) @@ -1145,7 +1146,7 @@ mod tests { let request = TokenRequest::new( " edge-7 ", "readwrite", - Some(vec![" temp ".into(), "humidity".into(), "temp".into()]), + Some(vec!["temp".into(), "humidity".into(), "temp".into()]), 3600, &ENDPOINT_RULES, ) @@ -1159,6 +1160,28 @@ mod tests { assert_eq!(request.duration_seconds, 3600); } + #[test] + fn sensor_names_are_kept_exactly_commas_and_spaces_included() { + let names = vec![ + "cpu,usage".to_string(), + " padded ".to_string(), + "cpu usage_idle".to_string(), + ]; + let request = + TokenRequest::new("a", "read", Some(names.clone()), 60, &ENDPOINT_RULES).unwrap(); + assert_eq!(request.sensors, Some(names)); + + // ...and the token matches them exactly + let config = make_config(); + let issued = request.issue(&config).unwrap(); + let access = validate_token(&bearer_headers(&issued.token), &config).unwrap(); + assert!(access.can_access_sensor("cpu,usage")); + assert!(access.can_access_sensor(" padded ")); + assert!(!access.can_access_sensor("padded")); + assert!(!access.can_access_sensor("cpu")); + assert!(!access.can_access_sensor("usage")); + } + #[test] fn requests_are_checked() { let ask = |subject: &str, scope: &str, sensors: Option>, duration: u64| { @@ -1173,6 +1196,7 @@ mod tests { assert!(ask("a", "read", None, ENDPOINT_RULES.max_duration_seconds + 1).is_err()); assert!(ask("a", "read", Some(vec![]), 60).is_err()); assert!(ask("a", "read", Some(vec!["".into()]), 60).is_err()); + assert!(ask("a", "read", Some(vec![" ".into()]), 60).is_err()); assert!(ask("a", "read", Some(vec!["x".repeat(257)]), 60).is_err()); assert!(ask("a", "read", Some(vec!["s".into(); 1001]), 60).is_err()); } diff --git a/src/http/crud.rs b/src/http/crud.rs index 18857330..8254c594 100644 --- a/src/http/crud.rs +++ b/src/http/crud.rs @@ -374,6 +374,7 @@ fn last_sample_json(samples: &crate::datamodel::TypedSamples) -> Option<(String, #[utoipa::path( get, path = "/metrics", + security(("bearer" = ["read"])), tag = "SensApp", params( ("name" = Option, Query, description = "Filter metrics by name (substring match)"), @@ -620,6 +621,7 @@ async fn list_filtered_series( #[utoipa::path( get, path = "/series", + security(("bearer" = ["read"])), tag = "SensApp", params( ("metric" = Option, Query, description = "Filter series by metric name"), @@ -829,6 +831,7 @@ pub async fn list_series( #[utoipa::path( get, path = "/series/{series_uuid}", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1038,6 +1041,7 @@ pub async fn get_series_data( #[utoipa::path( get, path = "/series/{series_uuid}/last", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1120,6 +1124,7 @@ pub async fn get_series_last_sample( #[utoipa::path( delete, path = "/series/{series_uuid}", + security(("bearer" = ["delete"])), tag = "Admin", params( ("series_uuid" = String, Path, description = "UUID of the series to delete") @@ -1188,6 +1193,7 @@ pub async fn delete_series( #[utoipa::path( delete, path = "/series/{series_uuid}/samples", + security(("bearer" = ["delete"])), tag = "Admin", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1277,6 +1283,7 @@ pub async fn delete_series_samples( #[utoipa::path( get, path = "/series/{series_uuid}/availability", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), diff --git a/src/http/influxdb.rs b/src/http/influxdb.rs index fe6995d6..d1ab6c08 100644 --- a/src/http/influxdb.rs +++ b/src/http/influxdb.rs @@ -164,6 +164,7 @@ impl FromStr for Precision { #[utoipa::path( post, path = "/api/v2/write", + security(("bearer" = ["write"])), tag = "InfluxDB", request_body( content = String, diff --git a/src/http/metrics.rs b/src/http/metrics.rs index 47687e60..06c05ae7 100644 --- a/src/http/metrics.rs +++ b/src/http/metrics.rs @@ -297,6 +297,8 @@ impl HttpMetrics { #[utoipa::path( get, path = "/prometheus/metrics", + // Public, except with `include_latest_samples=true`, which needs a read token + security((), ("bearer" = ["read"])), tag = "Observability", params( ("include_latest_samples" = Option, Query, description = "Append the most recent sample for Prometheus-compatible series"), diff --git a/src/http/prometheus_read.rs b/src/http/prometheus_read.rs index c813d9da..aeb13ac0 100644 --- a/src/http/prometheus_read.rs +++ b/src/http/prometheus_read.rs @@ -187,6 +187,7 @@ fn verify_read_headers(headers: &HeaderMap) -> Result<(), AppError> { #[utoipa::path( post, path = "/api/v1/prometheus_remote_read", + security(("bearer" = ["read"])), tag = "Prometheus", request_body( content_type = "application/x-protobuf", diff --git a/src/http/prometheus_write.rs b/src/http/prometheus_write.rs index 931d98ec..6d889f15 100644 --- a/src/http/prometheus_write.rs +++ b/src/http/prometheus_write.rs @@ -88,6 +88,7 @@ fn verify_headers(headers: &HeaderMap) -> Result<(), AppError> { #[utoipa::path( post, path = "/api/v1/prometheus_remote_write", + security(("bearer" = ["write"])), tag = "Prometheus", request_body( content_type = "application/x-protobuf", diff --git a/src/http/server.rs b/src/http/server.rs index abeedbe6..1c0bb6d1 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -59,8 +59,36 @@ use tracing::Level; use utoipa::{OpenApi, ToSchema}; use utoipa_scalar::{Scalar, Servable as ScalarServable}; +/// Declares the bearer token every protected operation refers to (`security` of each path). +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "A JWT signed with the secret of the server, sent as `Authorization: Bearer ` \ + (`Authorization: Token `, which InfluxDB clients send, works too). Each operation \ + lists the scope it needs: `read`, `write`, `delete` or `admin`. Make one with \ + `sensapp generate-token`, or with `POST /api/v1/admin/tokens` and an `admin` token \ + (the Credentials tab of the web UI does). With authentication disabled \ + (`SENSAPP_AUTH_DISABLED`) no token is needed.", + )) + .build(), + ), + ); + } +} + #[derive(OpenApi)] #[openapi( + modifiers(&SecurityAddon), tags( (name = "SensApp", description = "SensApp API"), (name = "InfluxDB", description = "InfluxDB Write API"), @@ -373,6 +401,7 @@ async fn frontpage(State(state): State) -> Result, #[utoipa::path( post, path = "/publish", + security(("bearer" = ["write"])), tag = "SensApp", request_body( content = String, @@ -582,6 +611,7 @@ struct CreatedToken { #[utoipa::path( post, path = "/api/v1/admin/tokens", + security(("bearer" = ["admin"])), tag = "Admin", request_body = CreateTokenRequest, responses( @@ -658,6 +688,7 @@ async fn create_token( #[utoipa::path( post, path = "/api/v1/admin/vacuum", + security(("bearer" = ["delete"])), tag = "Admin", responses( (status = 200, description = "Maintenance completed, with the number of duplicate samples removed", body = VacuumResponse), @@ -794,6 +825,68 @@ mod tests { } } + #[test] + fn protected_operations_declare_the_scope_they_need() { + let document = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); + assert_eq!( + document["components"]["securitySchemes"]["bearer"]["scheme"], + "bearer" + ); + + let public = ["/", "/health/live", "/health/ready", "/docs"]; + for (path, operations) in document["paths"].as_object().expect("OpenAPI paths") { + for (method, operation) in operations.as_object().expect("operations") { + let security = operation.get("security"); + if public.contains(&path.as_str()) { + assert!(security.is_none(), "{method} {path} is public"); + continue; + } + let requirements = security + .and_then(|security| security.as_array()) + .unwrap_or_else(|| panic!("{method} {path} does not declare its scope")); + // A token with one scope, or (the service metrics) no token at all + let scopes: Vec<&str> = requirements + .iter() + .filter_map(|requirement| requirement.get("bearer")) + .flat_map(|scopes| scopes.as_array().unwrap()) + .filter_map(|scope| scope.as_str()) + .collect(); + assert_eq!(scopes.len(), 1, "{method} {path}: {requirements:?}"); + assert!( + ["read", "write", "delete", "admin"].contains(&scopes[0]), + "{method} {path}: {scopes:?}" + ); + } + } + + let scope_of = |path: &str, method: &str| -> String { + document["paths"][path][method]["security"] + .as_array() + .and_then(|requirements| { + requirements + .iter() + .find_map(|requirement| requirement["bearer"][0].as_str()) + }) + .unwrap_or_default() + .to_string() + }; + assert_eq!(scope_of("/publish", "post"), "write"); + assert_eq!(scope_of("/api/v2/write", "post"), "write"); + assert_eq!(scope_of("/series/{series_uuid}", "get"), "read"); + assert_eq!(scope_of("/series/{series_uuid}", "delete"), "delete"); + assert_eq!(scope_of("/api/v1/admin/vacuum", "post"), "delete"); + assert_eq!(scope_of("/api/v1/admin/tokens", "post"), "admin"); + // Public, but a token with `read` gets the latest samples + let metrics = &document["paths"]["/prometheus/metrics"]["get"]["security"]; + assert!( + metrics + .as_array() + .unwrap() + .iter() + .any(|requirement| requirement.as_object().unwrap().is_empty()) + ); + } + /// Helper to get test database URL - uses the centralized constant from test_utils fn get_test_database_url() -> String { sensapp::test_utils::get_test_database_url() diff --git a/src/http/simple_promql.rs b/src/http/simple_promql.rs index 7ca028b2..addbc5fb 100644 --- a/src/http/simple_promql.rs +++ b/src/http/simple_promql.rs @@ -242,6 +242,7 @@ fn parse_promql_query(query: &str) -> Result { #[utoipa::path( get, path = "/api/v1/query", + security(("bearer" = ["read"])), tag = "SensApp", params( ("query" = String, Query, description = "PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')"), diff --git a/src/main.rs b/src/main.rs index 6828125b..d1abc1ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -199,7 +199,8 @@ fn build_auth( /// Usage: sensapp generate-token [OPTIONS] /// --scope (default: "read write") /// --duration (default: 3600) -/// --sensors (optional sensor allow list) +/// --sensors (optional sensor allow list, comma separated) +/// --sensor (one sensor name as it is, commas included; repeat it) fn generate_token_command(args: &[String]) -> Result<()> { load_configuration().context("Failed to load configuration")?; let config = config::get().context("Failed to get configuration")?; @@ -220,7 +221,12 @@ fn generate_token_command(args: &[String]) -> Result<()> { " --scope Comma-separated read, write, delete, admin, or readwrite (default: \"read write\")" ); eprintln!(" --duration Token validity duration (default: 3600)"); - eprintln!(" --sensors Restrict to specific sensors"); + eprintln!( + " --sensors Restrict to specific sensors, comma separated" + ); + eprintln!( + " --sensor Restrict to one sensor, named as it is (a comma is part of the name). Repeat it" + ); eprintln!(); eprintln!("Environment:"); eprintln!(" SENSAPP_JWT_SECRET Required. The shared secret for signing."); @@ -249,14 +255,18 @@ fn generate_token_command(args: &[String]) -> Result<()> { "--sensors" => { i += 1; let raw = args.get(i).context("--sensors requires a value")?; - sensors = Some( + sensors.get_or_insert_with(Vec::new).extend( raw.split(',') .map(str::trim) .filter(|name| !name.is_empty()) - .map(str::to_string) - .collect(), + .map(str::to_string), ); } + "--sensor" => { + i += 1; + let name = args.get(i).context("--sensor requires a name")?; + sensors.get_or_insert_with(Vec::new).push(name.clone()); + } other => { anyhow::bail!("Unknown option: {other}"); } diff --git a/tests/integration/real_router.rs b/tests/integration/real_router.rs index 8e678b82..b1bfa4dd 100644 --- a/tests/integration/real_router.rs +++ b/tests/integration/real_router.rs @@ -769,11 +769,12 @@ async fn an_admin_makes_tokens_that_do_what_they_were_made_for() -> Result<()> { // The sensors of the request limit the token let mut wish = token_wish("one-sensor", &["read", "write"], 60); - wish["sensors"] = serde_json::json!([" only_this ", "only_this"]); + wish["sensors"] = serde_json::json!(["only,this", "only,this"]); let (status, _, body) = send(&router, create_token_request(Some(&admin), wish)).await; assert_eq!(status, StatusCode::OK, "{body}"); let created: serde_json::Value = serde_json::from_str(&body)?; - assert_eq!(created["sensors"], serde_json::json!(["only_this"])); + // A comma is part of a name, and a repeated name is kept once + assert_eq!(created["sensors"], serde_json::json!(["only,this"])); assert_eq!(created["scope"], serde_json::json!(["read", "write"])); Ok(()) } From 90090194885507f66dffdcd9aba83085f7c98b5a Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:06:00 +0200 Subject: [PATCH 42/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20Credentia?= =?UTF-8?q?ls=20in=20the=20layout=20of=20Load=20Data,=20with=20the=20comma?= =?UTF-8?q?nd=20line,=20and=20a=20token=20section=20in=20every=20way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Credentials page loses its card and takes the whole width as Load Data does. The validity comes before the scopes, the sensors are chips (a name is kept as typed, so a comma or a space works), and a Command line section shows the `sensapp generate-token` command of what the form says, quoted for the shell, which also covers what the endpoint cannot do: an admin token. "Make it here" is the button for an admin token, and tells how to get one otherwise. Load Data starts every way with the same "A token" section (where the code finds the token, how to make one, a link to Credentials) when the server asks for a token, instead of comments scattered in the code and a paragraph at the top. Co-Authored-By: Claude Sonnet 5.5 --- docs/FRONTEND.md | 19 +- frontend/src/lib/credentials.test.ts | 60 +++- frontend/src/lib/credentials.ts | 40 ++- frontend/src/lib/loadSnippets.test.ts | 13 + frontend/src/lib/loadSnippets.ts | 51 ++- frontend/src/pages/CredentialsPage.test.tsx | 111 ++++-- frontend/src/pages/CredentialsPage.tsx | 360 ++++++++++++-------- frontend/src/pages/LoadPage.test.tsx | 17 +- frontend/src/pages/LoadPage.tsx | 15 +- 9 files changed, 459 insertions(+), 227 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 31b404a1..022e362c 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -51,22 +51,27 @@ The second tab of the header (`/ui/load`) explains how to get data into SensApp, - **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write` with a few inputs (see [INFLUX_DB.md](INFLUX_DB.md)), and how to check it (`--once`). - **Prometheus**: `remote_write` and `remote_read` (`read_recent: true`), and the address to use from a container. - **curl**: SenML JSON, CSV and InfluxDB line protocol, to `/publish` and `/api/v2/write`. -- The server is the origin of the page. When it asked for a token (or one is in use) the code reads it from `SENSAPP_TOKEN` and the page says how to make a `write` one (`sensapp generate-token me --scope write`); **no token is ever written in the code**. Prometheus reads its token from a file, so its tab gives the command that writes it (a year long, for reading and writing). +- The server is the origin of the page. When it asked for a token (or one is in use) every way starts with the same **A token** section: where the code finds it (`SENSAPP_TOKEN`), the command that makes a `write` one (`sensapp generate-token me --scope write`) and a link to the [Credentials](#credentials) tab; **no token is ever written in the code**. Prometheus reads its token from a file, so its section gives the command that writes it (a year long, for reading and writing). With an open server there is no such section. - **Telegraf and tokens.** Telegraf sends its `token` as `Authorization: Token …`, the InfluxDB way, and SensApp reads that scheme as well as `Bearer …`. The snippet gives Telegraf its own `token = "${SENSAPP_TOKEN}"`, no header to rewrite. Checked against a server with a secret. - The snippets are in `src/lib/loadSnippets.ts` (one function for each way, tested), the page in `src/pages/LoadPage.tsx`. The page is loaded when its tab is first opened (5 kB gzipped, the highlighter is shared with the Code dialog). - All of them were run against a real SensApp (PostgreSQL), with and without a JWT secret (the Telegraf one before SensApp read the `Token` scheme, with a `Bearer` header): the Python scripts, the curl commands, Telegraf (`--once`), and the Prometheus configuration with `promtool check config`. ## Credentials -The third tab (`/ui/credentials`) makes tokens for the clients of SensApp (see [JWT_AUTH.md](JWT_AUTH.md)). What it shows depends on the token in use: +The third tab (`/ui/credentials`) makes tokens for the clients of SensApp (see [JWT_AUTH.md](JWT_AUTH.md)). It has the layout of Load Data: a title and a sentence on the left, the choice on the right, the whole width, no card. The choices, by weight: -- **No token, and the server answers without one**: "Authentication is disabled", with how to turn it on. -- **No token, or a token without the `admin` scope**: "An admin token is needed", with the command that makes one (`sensapp generate-token me --scope admin`) and a button that opens the sign-in dialog (which then suggests that command instead of a read token). A local SensApp without a secret prints a token with every scope, admin included, when it starts. -- **An admin token**: a form with the name of the token (it is in the logs of its requests), the scopes (`read` and `write` by default, `delete` on demand, never `admin`), the sensors it is limited to (names separated by commas; the sensors of the server are offered to click when the token in use can read them, which an admin-only token cannot), and the validity (1 hour, 1 day, 30 days, 1 year; a day by default). The server's own cap (`SENSAPP_TOKEN_MAX_DURATION_SECONDS`) and checks answer in the form. +1. **Name**: who or what the token is for. It is the `sub` of the token, so it is in the logs of every request the token makes; it grants nothing. +2. **Valid for**: 1 hour, 1 day (the default), 30 days, 1 year. A token cannot be revoked, so this is the main safeguard. +3. **What it may do**: `read` and `write` by default, `delete` on demand, and `admin`, which only the command line makes. +4. **Only these sensors**: type a name and press Enter (or Add), remove it with its ×. A name is kept exactly as typed, spaces and commas included, and the sensors of the server are offered to click when the token in use can read them (an admin-only token cannot). +5. **Command line**: the `sensapp generate-token …` command for what the form says, updated as it changes, with a `--sensor` for each name and everything quoted for the shell. It works without an admin token, needs the secret (so it runs where SensApp runs: `docker exec`, `kubectl exec`), and is the only way to make an admin token. +6. **Make it here**: the button, for an admin token (`POST /api/v1/admin/tokens`). Without one the section says how to make one and opens the sign-in dialog, which then suggests the admin command instead of a read token. -The token is then **shown once**, with a Copy button, what it was made with and the `export SENSAPP_TOKEN=…` line that the code of [Load Data](#load-data) reads. SensApp keeps nothing: the page has no list of tokens and cannot revoke one (rotating the secret does, see [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens)). The page does not keep the token either: it is dropped from the cache of the requests as soon as the page is left, and a refresh forgets it. +The token is then **shown once** at the top of the page, with a Copy button, what it was made with and the `export SENSAPP_TOKEN=…` line that [Load Data](#load-data) reads. SensApp keeps nothing: the page has no list of tokens and cannot revoke one (rotating the secret does, see [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens)). The page does not keep the token either: it is dropped from the cache of the requests when the page is left, and a refresh forgets it. -The page is in `src/pages/CredentialsPage.tsx` and loaded when its tab is first opened (it shows the token as code, with the highlighter). With an admin token the catalog is not read, because an admin token cannot read: the refusal would ask for a token again. +With no token, a server that answers anyway has authentication disabled: the page says so and shows nothing else. With an admin token the catalog is not read, because an admin token cannot read and the refusal would ask for a token again. + +The page is in `src/pages/CredentialsPage.tsx` (the command and the checks of a name in `src/lib/credentials.ts`) and loaded when its tab is first opened (it shows code, with the highlighter). ## Address diff --git a/frontend/src/lib/credentials.test.ts b/frontend/src/lib/credentials.test.ts index b4582d1c..1b50244f 100644 --- a/frontend/src/lib/credentials.test.ts +++ b/frontend/src/lib/credentials.test.ts @@ -1,18 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { parseSensorNames, tokenScopes } from './credentials'; +import { addSensor, shellWord, tokenCommand, tokenScopes } from './credentials'; -describe('parseSensorNames', () => { - it('trims, drops blanks and repeats, and keeps the order', () => { - expect(parseSensorNames(' temperature ,humidity,, temperature , ')).toEqual(['temperature', 'humidity']); +describe('addSensor', () => { + it('adds a name and keeps the order', () => { + expect(addSensor(addSensor([], 'temperature'), 'humidity')).toEqual(['temperature', 'humidity']); }); - it('is empty for nothing', () => { - expect(parseSensorNames('')).toEqual([]); - expect(parseSensorNames(' , ')).toEqual([]); + it('keeps a name exactly as typed, commas and spaces included', () => { + expect(addSensor([], 'cpu,usage')).toEqual(['cpu,usage']); + expect(addSensor([], ' padded ')).toEqual([' padded ']); + expect(addSensor(['cpu usage_idle'], 'cpu usage_idle')).toEqual(['cpu usage_idle']); }); - it('keeps the names that hold spaces, as sensors do', () => { - expect(parseSensorNames('cpu usage_idle, mem used')).toEqual(['cpu usage_idle', 'mem used']); + it('does not add a blank name nor one that is there already', () => { + const sensors = ['temperature']; + expect(addSensor(sensors, '')).toBe(sensors); + expect(addSensor(sensors, ' ')).toBe(sensors); + expect(addSensor(sensors, 'temperature')).toBe(sensors); }); }); @@ -27,3 +31,41 @@ describe('tokenScopes', () => { expect(tokenScopes(null)).toEqual(['read', 'write']); }); }); + +describe('shellWord', () => { + it('leaves a plain word bare and quotes the rest', () => { + expect(shellWord('edge-device_7')).toBe('edge-device_7'); + expect(shellWord('cpu usage')).toBe("'cpu usage'"); + expect(shellWord('cpu,usage')).toBe("'cpu,usage'"); + expect(shellWord("it's")).toBe(`'it'\\''s'`); + // Nothing a name holds can add a command + expect(shellWord('x; rm -rf ~')).toBe("'x; rm -rf ~'"); + expect(shellWord('$(whoami)')).toBe("'$(whoami)'"); + }); +}); + +describe('tokenCommand', () => { + const wish = { subject: 'edge-7', scope: ['read', 'write'], sensors: [], durationSeconds: 86400 }; + + it('is one line without sensors', () => { + expect(tokenCommand(wish)).toBe('sensapp generate-token edge-7 --scope read,write --duration 86400'); + }); + + it('gives each sensor a --sensor of its own, commas kept in the name', () => { + expect(tokenCommand({ ...wish, sensors: ['temperature', 'cpu,usage', 'mem used'] })).toBe( + "sensapp generate-token edge-7 --scope read,write --duration 86400 \\\n --sensor temperature \\\n --sensor 'cpu,usage' \\\n --sensor 'mem used'", + ); + }); + + it('quotes the name of the token', () => { + expect(tokenCommand({ ...wish, subject: "bob's phone" })).toContain(`'bob'\\''s phone'`); + }); + + it('says what is still to choose', () => { + expect(tokenCommand({ ...wish, subject: ' ', scope: [] })).toBe('sensapp generate-token NAME --scope SCOPE --duration 86400'); + }); + + it('can make an admin token, which the endpoint cannot', () => { + expect(tokenCommand({ ...wish, scope: ['admin'], durationSeconds: 3600 })).toBe('sensapp generate-token edge-7 --scope admin --duration 3600'); + }); +}); diff --git a/frontend/src/lib/credentials.ts b/frontend/src/lib/credentials.ts index 9f4163c1..33f0ef60 100644 --- a/frontend/src/lib/credentials.ts +++ b/frontend/src/lib/credentials.ts @@ -3,12 +3,46 @@ import type { TokenInfo } from '../stores/useAuthStore'; /** What makes a token that can make tokens. It reads nothing, and the endpoint never makes another one. */ export const ADMIN_TOKEN_COMMAND = 'sensapp generate-token me --scope admin'; -/** The names of a comma separated list, trimmed, without blanks nor repeats. */ -export function parseSensorNames(text: string): string[] { - return [...new Set(text.split(',').map((name) => name.trim()).filter(Boolean))]; +/** What the form asks for. */ +export interface TokenWish { + subject: string; + scope: string[]; + /** Names as they are: a name may hold a comma or a space */ + sensors: string[]; + durationSeconds: number; +} + +/** + * The sensors with a name added. A name is kept exactly as typed (the server matches it exactly, + * commas and spaces included): only a blank name, or one that is there already, is not added. + */ +export function addSensor(sensors: string[], name: string): string[] { + if (name.trim() === '' || sensors.includes(name)) return sensors; + return [...sensors, name]; } /** The scopes a token says it has. A token without the claim has the default one of the server. */ export function tokenScopes(info: TokenInfo | null): string[] { return info?.scope === undefined ? ['read', 'write'] : info.scope.split(/\s+/).filter(Boolean); } + +/** A shell word that is one word whatever it contains, left bare when it is plain. */ +export function shellWord(value: string): string { + return /^[A-Za-z0-9_@%+=:./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * The command that makes what the form describes, for `sensapp generate-token`: the way to make an + * admin token, and to make any token where only the command line reaches the secret. Each sensor + * is a `--sensor` of its own, as a name may hold a comma. + */ +export function tokenCommand(wish: TokenWish): string { + const line = [ + 'sensapp generate-token', + wish.subject.trim() === '' ? 'NAME' : shellWord(wish.subject), + `--scope ${wish.scope.length > 0 ? wish.scope.join(',') : 'SCOPE'}`, + `--duration ${wish.durationSeconds}`, + ].join(' '); + if (wish.sensors.length === 0) return line; + return [line, ...wish.sensors.map((name) => `--sensor ${shellWord(name)}`)].join(' \\\n '); +} diff --git a/frontend/src/lib/loadSnippets.test.ts b/frontend/src/lib/loadSnippets.test.ts index 99060670..86def9a6 100644 --- a/frontend/src/lib/loadSnippets.test.ts +++ b/frontend/src/lib/loadSnippets.test.ts @@ -37,6 +37,19 @@ describe('load snippets', () => { expect(sections[2].code).toContain('SamplePoint(datetime.now(UTC)'); }); + it('open with a token section, the same for every way, that links to Credentials', () => { + for (const way of LOAD_WAYS) { + expect(sectionsFor(way.id, open).map((s) => s.title), way.id).not.toContain('A token'); + const [first] = sectionsFor(way.id, secured); + expect(first.title, way.id).toBe('A token'); + expect(first.link, way.id).toEqual({ to: '/credentials', label: 'Make a token in Credentials' }); + // The other sections do not repeat how to make one + for (const section of sectionsFor(way.id, secured).slice(1)) { + expect(section.code, way.id).not.toContain('generate-token'); + } + } + }); + it('python: the client takes the token of the environment', () => { const code = codeOf('python', secured); expect(code).toContain('import os'); diff --git a/frontend/src/lib/loadSnippets.ts b/frontend/src/lib/loadSnippets.ts index c18b1800..445e6cd0 100644 --- a/frontend/src/lib/loadSnippets.ts +++ b/frontend/src/lib/loadSnippets.ts @@ -16,6 +16,8 @@ export interface LoadSection { description: string; language: LoadLanguage; code: string; + /** A page of the UI that goes with the section */ + link?: { to: string; label: string }; } export const LOAD_WAYS = [ @@ -29,6 +31,27 @@ export type LoadWay = (typeof LOAD_WAYS)[number]['id']; /** What makes a token that can write. The read one of the Code dialog cannot. */ export const WRITE_TOKEN_COMMAND = 'sensapp generate-token me --scope write'; +/** Where a token is made in the UI. */ +const CREDENTIALS_LINK = { to: '/credentials', label: 'Make a token in Credentials' }; + +/** + * The first section of a way when the server asks for a token: where the code finds it, and how to + * make one. The code never contains a token, it reads `SENSAPP_TOKEN`. + */ +function tokenSection(input: LoadInput): LoadSection[] { + if (!input.authenticated) return []; + return [ + { + title: 'A token', + description: + 'This server asks for a token. The code reads it from `SENSAPP_TOKEN`. Make one with the `write` scope in the Credentials tab, or with the command line, and keep it in the environment.', + language: 'bash', + code: `export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})\n`, + link: CREDENTIALS_LINK, + }, + ]; +} + /** A Python string literal. A JSON string is one, with its escapes. */ const py = (value: string) => JSON.stringify(value); @@ -43,12 +66,11 @@ function pythonClient(input: LoadInput, indent: string): string[] { } /** The head of a script: the block `uv run` reads, then the imports. */ -function pythonHead(input: LoadInput, file: string, imports: string[]): string[] { +function pythonHead(file: string, imports: string[]): string[] { return [ ...SCRIPT_METADATA, '#', `# Run it with \`uv run ${file}\``, - ...(input.authenticated ? ['#', '# The token comes from the environment, make one with:', `# export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})`] : []), '', ...imports, ]; @@ -58,7 +80,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { const os = input.authenticated ? ['import os'] : []; const frame = [ - ...pythonHead(input, 'load.py', ['import asyncio', 'from datetime import UTC, datetime, timedelta', ...os, '', 'import polars as pl', 'from sensapp import SensAppClient']), + ...pythonHead('load.py', ['import asyncio', 'from datetime import UTC, datetime, timedelta', ...os, '', 'import polars as pl', 'from sensapp import SensAppClient']), '', '# One row per sample: "timestamp" (with a time zone) and "value".', '# Or read yours: pl.read_csv("data.csv", try_parse_dates=True), pl.read_parquet(...), pl.from_pandas(df).', @@ -83,7 +105,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { ]; const one = [ - ...pythonHead(input, 'sample.py', ['import asyncio', ...os, '', 'from sensapp import SensAppClient']), + ...pythonHead('sample.py', ['import asyncio', ...os, '', 'from sensapp import SensAppClient']), '', '', 'async def main() -> None:', @@ -96,7 +118,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { ]; const few = [ - ...pythonHead(input, 'stream.py', ['import asyncio', 'import random', 'from datetime import UTC, datetime', ...os, '', 'from sensapp import SamplePoint, SensAppClient']), + ...pythonHead('stream.py', ['import asyncio', 'import random', 'from datetime import UTC, datetime', ...os, '', 'from sensapp import SamplePoint, SensAppClient']), '', '', 'def read_temperature() -> float:', @@ -119,6 +141,7 @@ export function pythonSections(input: LoadInput): LoadSection[] { ]; return [ + ...tokenSection(input), { title: 'DataFrame', description: 'Send a table of timestamps and values. Publishing the same name again appends to the same series.', @@ -155,8 +178,6 @@ export function telegrafSections(input: LoadInput): LoadSection[] { ' influx_uint_support = true', ...(input.authenticated ? [ - ' # Make a token with: ' + WRITE_TOKEN_COMMAND, - ' # and start Telegraf with it in SENSAPP_TOKEN.', ' token = "${SENSAPP_TOKEN}"', ] : []), @@ -172,6 +193,7 @@ export function telegrafSections(input: LoadInput): LoadSection[] { ' ignore_fs = ["tmpfs", "devtmpfs", "devfs", "overlay", "squashfs"]', ]; return [ + ...tokenSection(input), { title: 'telegraf.conf', description: @@ -183,11 +205,7 @@ export function telegrafSections(input: LoadInput): LoadSection[] { title: 'Run it', description: 'One collection to test, then the daemon.', language: 'bash', - code: [ - ...(input.authenticated ? [`export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})`] : []), - 'telegraf --config telegraf.conf --once', - 'telegraf --config telegraf.conf', - ].join('\n') + '\n', + code: ['telegraf --config telegraf.conf --once', 'telegraf --config telegraf.conf'].join('\n') + '\n', }, ]; } @@ -218,9 +236,11 @@ export function prometheusSections(input: LoadInput): LoadSection[] { ? [ { title: 'A token', - description: 'This server asks for a token. Prometheus reads it from a file. This one can read and write, and lasts a year (the default is an hour).', + description: + 'This server asks for a token. Prometheus reads it from a file, not from the environment. Make one that can read and write, and lasts a year, in the Credentials tab (and write it to the file), or with the command line:', language: 'bash' as const, code: PROMETHEUS_TOKEN_COMMAND + '\n', + link: CREDENTIALS_LINK, }, ] : []), @@ -242,27 +262,24 @@ export function prometheusSections(input: LoadInput): LoadSection[] { export function curlSections(input: LoadInput): LoadSection[] { const header = input.authenticated ? ['-H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; - const token = input.authenticated ? [`# SENSAPP_TOKEN holds a token, made with:`, `# ${WRITE_TOKEN_COMMAND}`, ''] : []; /** One request: each option on its own line, the address last. */ const request = (options: string[], url: string) => ['curl -sS --fail-with-body \\', ...[...header, ...options].map((line) => ` ${line} \\`), ` ${url}`]; const publish = sh(`${input.baseUrl}/publish`); const write = sh(`${input.baseUrl}/api/v2/write?org=sensapp&bucket=home&precision=s`); const senml = [ - ...token, ...request(['--json ' + sh('[{"n":"temperature","u":"Cel","v":21.5},{"n":"humidity","u":"%RH","v":41,"t":1767225600}]')], publish), ]; const csv = [ - ...token, "printf 'datetime,sensor_name,value,unit\\n2026-01-01T12:00:00Z,temperature,21.5,Cel\\n2026-01-01T12:01:00Z,temperature,21.7,Cel\\n' > measurements.csv", '', ...request(['-H ' + sh('content-type: text/csv'), '--data-binary @measurements.csv'], publish), ]; const line = [ - ...token, ...request(['--data-binary ' + sh('weather,location=oslo temperature=21.5,humidity=41i 1767225600')], write), ]; return [ + ...tokenSection(input), { title: 'SenML JSON', description: 'RFC 8428. `n` is the name, `u` the unit, `v` the value, `t` a Unix time in seconds (now when missing).', language: 'bash', code: senml.join('\n') + '\n' }, { title: 'CSV', description: 'The columns are found by name: a datetime, a `sensor_name`, a `value`, and optionally a `unit`.', language: 'bash', code: csv.join('\n') + '\n' }, { title: 'InfluxDB line protocol', description: '`measurement,tag=value field=value timestamp`. The tags become labels. `org` and `bucket` are required, `precision=s` makes the timestamp seconds.', language: 'bash', code: line.join('\n') + '\n' }, diff --git a/frontend/src/pages/CredentialsPage.test.tsx b/frontend/src/pages/CredentialsPage.test.tsx index ee3db41e..f28b6777 100644 --- a/frontend/src/pages/CredentialsPage.test.tsx +++ b/frontend/src/pages/CredentialsPage.test.tsx @@ -50,6 +50,8 @@ function renderPage() { ); } +const command = () => screen.getByLabelText('Command that makes the token').textContent ?? ''; + beforeEach(() => { sessionStorage.clear(); useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); @@ -67,26 +69,36 @@ describe('CredentialsPage', () => { it('says that nothing needs a token when the server is open', async () => { renderPage(); expect(await screen.findByText('Authentication is disabled')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Name' })).toBeNull(); + }); + + it('asks for the choices in the order of their weight: the validity before the scopes', async () => { + useAuthStore.setState({ token: ADMIN }); + renderPage(); + await screen.findByRole('textbox', { name: 'Name' }); + const titles = screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent); + expect(titles).toEqual(['Name', 'Valid for', 'What it may do', 'Only these sensors', 'Command line', 'Make it here']); }); - it('asks for an admin token, and says how to make one, when the server wants a token', async () => { + it('shows the form and the command, and how to get an admin token, to a server that wants a token', async () => { mockListMetrics.mockResolvedValue({ error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }); renderPage(); - expect(await screen.findByText('An admin token is needed')).toBeInTheDocument(); + expect(await screen.findByRole('textbox', { name: 'Name' })).toBeInTheDocument(); + expect(command()).toContain('sensapp generate-token NAME --scope read,write --duration 86400'); expect(screen.getByLabelText('Command that makes an admin token')).toHaveTextContent('sensapp generate-token me --scope admin'); + expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); // The sign-in dialog opened with the answer of the server, and the command of an admin token const dialog = await screen.findByRole('dialog'); expect(dialog).toHaveTextContent('sensapp generate-token me --scope admin'); expect(dialog).not.toHaveTextContent('--scope read'); }); - it('says that the token in use is not an admin one, and does not make the form available', async () => { + it('says that the token in use is not an admin one, and leaves the command', async () => { useAuthStore.setState({ token: READER }); renderPage(); - expect(await screen.findByText(/does not have the/)).toBeInTheDocument(); + expect(await screen.findByText(/does not have it/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); - expect(mockListMetrics).not.toHaveBeenCalled(); + expect(command()).toContain('sensapp generate-token NAME'); }); it('does not read the catalog with an admin token, which cannot read: the refusal would ask for a token again', async () => { @@ -97,7 +109,37 @@ describe('CredentialsPage', () => { expect(useAuthStore.getState().dialogOpen).toBe(false); }); - it('makes a token with what the form says, shows it once, and makes another', async () => { + it('writes the command as the form is filled, quoted, with a --sensor for each name', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), "bob's phone"); + await user.click(screen.getByRole('radio', { name: '1 hour' })); + await user.click(screen.getByRole('checkbox', { name: /^delete/ })); + const sensor = screen.getByRole('textbox', { name: 'Sensor name' }); + await user.type(sensor, 'cpu,usage{Enter}'); + await user.type(sensor, 'temperature{Enter}'); + + expect(command()).toBe( + "sensapp generate-token 'bob'\\''s phone' --scope read,write,delete --duration 3600 \\\n --sensor 'cpu,usage' \\\n --sensor temperature\n", + ); + }); + + it('makes an admin token with the command only', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'root'); + await user.click(screen.getByRole('checkbox', { name: /^admin/ })); + expect(command()).toContain('--scope read,write,admin'); + expect(screen.getByText(/only made with the command line/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeDisabled(); + expect(mockCreateToken).not.toHaveBeenCalled(); + }); + + it('makes a token with what the form says, shows it once, then lets it go', async () => { const user = userEvent.setup(); useAuthStore.setState({ token: ADMIN }); mockCreateToken.mockResolvedValue({ data: created }); @@ -106,20 +148,21 @@ describe('CredentialsPage', () => { const submit = await screen.findByRole('button', { name: 'Make the token' }); // A name is needed, and at least one scope expect(submit).toBeDisabled(); - await user.type(screen.getByRole('textbox', { name: /^Name/ }), ' edge-7 '); + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' edge-7 '); expect(submit).toBeEnabled(); - await user.click(screen.getByRole('checkbox', { name: /^delete/ })); - await user.click(screen.getByRole('checkbox', { name: /^delete/ })); - await user.type(screen.getByRole('textbox', { name: /Only these sensors/ }), 'temperature, humidity, temperature'); - await user.selectOptions(screen.getByRole('combobox', { name: /Valid for/ }), '1 hour'); + await user.click(screen.getByRole('radio', { name: '1 hour' })); + const sensor = screen.getByRole('textbox', { name: 'Sensor name' }); + await user.type(sensor, 'temperature{Enter}'); + await user.type(sensor, 'cpu,usage{Enter}'); + await user.type(sensor, 'temperature{Enter}'); await user.click(submit); + // A comma is part of a name, and the repeated name is kept once expect(mockCreateToken).toHaveBeenCalledWith({ - body: { subject: 'edge-7', scope: ['read', 'write'], sensors: ['temperature', 'humidity'], duration_seconds: 3600 }, + body: { subject: 'edge-7', scope: ['read', 'write'], sensors: ['temperature', 'cpu,usage'], duration_seconds: 3600 }, }); - // The token, shown with the warning that it is the only time - expect(await screen.findByText(/only time the token is shown/)).toBeInTheDocument(); + expect(await screen.findByText(/only time it is shown/)).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'Token' })).toHaveValue('made.by.sensapp'); expect(screen.getByLabelText('Export of the token')).toHaveTextContent('export SENSAPP_TOKEN=made.by.sensapp'); expect(screen.getByText(created.jti)).toBeInTheDocument(); @@ -128,24 +171,30 @@ describe('CredentialsPage', () => { expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument(); expect(await navigator.clipboard.readText()).toBe('made.by.sensapp'); - await user.click(screen.getByRole('button', { name: 'Make another token' })); - expect(await screen.findByRole('button', { name: 'Make the token' })).toBeInTheDocument(); - expect(screen.queryByRole('textbox', { name: 'Token' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Token' })).toBeNull()); + // The form is still there, with what was typed + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(' edge-7 '); }); - it('sends no sensors for the whole server, and lets the scopes be chosen', async () => { + it('lets a sensor go, and does not add a blank one', async () => { const user = userEvent.setup(); useAuthStore.setState({ token: ADMIN }); mockCreateToken.mockResolvedValue({ data: created }); renderPage(); - await user.type(await screen.findByRole('textbox', { name: /^Name/ }), 'dashboard'); - await user.click(screen.getByRole('checkbox', { name: /^write/ })); - await user.click(screen.getByRole('button', { name: 'Make the token' })); - - expect(mockCreateToken).toHaveBeenCalledWith({ - body: { subject: 'dashboard', scope: ['read'], sensors: null, duration_seconds: 86400 }, - }); + const sensor = await screen.findByRole('textbox', { name: 'Sensor name' }); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + await user.type(sensor, ' {Enter}'); + expect(screen.queryByRole('list', { name: 'Allowed sensors' })).toBeNull(); + + await user.type(sensor, 'temperature{Enter}humidity{Enter}'); + const chips = screen.getByRole('list', { name: 'Allowed sensors' }); + expect(within(chips).getAllByRole('listitem')).toHaveLength(2); + await user.click(screen.getByRole('button', { name: 'Remove temperature' })); + expect(within(screen.getByRole('list', { name: 'Allowed sensors' })).getAllByRole('listitem')).toHaveLength(1); + expect(command()).toContain('--sensor humidity'); + expect(command()).not.toContain('temperature'); }); it('offers the sensors of the server to a token that can read, to click in', async () => { @@ -153,10 +202,9 @@ describe('CredentialsPage', () => { useAuthStore.setState({ token: LOCAL }); renderPage(); - const box = await screen.findByRole('textbox', { name: /Only these sensors/ }); await user.click(await screen.findByRole('button', { name: 'temperature' })); await user.click(screen.getByRole('button', { name: 'humidity' })); - expect(box).toHaveValue('temperature, humidity'); + expect(within(screen.getByRole('list', { name: 'Allowed sensors' })).getAllByRole('listitem')).toHaveLength(2); // What is chosen is not offered again expect(screen.queryByRole('button', { name: 'temperature' })).toBeNull(); }); @@ -170,12 +218,11 @@ describe('CredentialsPage', () => { }); renderPage(); - await user.type(await screen.findByRole('textbox', { name: /^Name/ }), 'edge-7'); + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'edge-7'); await user.click(screen.getByRole('button', { name: 'Make the token' })); expect(await screen.findByRole('alert')).toHaveTextContent('The duration cannot be longer than 3600 seconds'); - // The form is still there with what was typed - expect(screen.getByRole('textbox', { name: /^Name/ })).toHaveValue('edge-7'); + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('edge-7'); expect(useAuthStore.getState().dialogOpen).toBe(false); }); @@ -185,7 +232,7 @@ describe('CredentialsPage', () => { mockCreateToken.mockResolvedValue({ error: { error: 'admin access required' }, response: { status: 403 } }); renderPage(); - await user.type(await screen.findByRole('textbox', { name: /^Name/ }), 'edge-7'); + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'edge-7'); await user.click(screen.getByRole('button', { name: 'Make the token' })); await waitFor(() => expect(useAuthStore.getState().dialogOpen).toBe(true)); diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx index 31054a8a..3592415e 100644 --- a/frontend/src/pages/CredentialsPage.tsx +++ b/frontend/src/pages/CredentialsPage.tsx @@ -9,7 +9,7 @@ import { CodeBlock } from '../components/CodeBlock'; import { Loading } from '../components/Feedback'; import { useMetrics } from '../hooks/useMetrics'; import { copyText } from '../lib/copyText'; -import { ADMIN_TOKEN_COMMAND, parseSensorNames, tokenScopes } from '../lib/credentials'; +import { ADMIN_TOKEN_COMMAND, addSensor, tokenCommand, tokenScopes } from '../lib/credentials'; import { describeToken, useAuthStore } from '../stores/useAuthStore'; const DAY = 24 * 3600; @@ -24,71 +24,104 @@ const SCOPES = [ { id: 'read', help: 'Read the series and the samples' }, { id: 'write', help: 'Send samples' }, { id: 'delete', help: 'Delete series and samples, and run the maintenance' }, + { id: 'admin', help: 'Make tokens, nothing else. Only with the command line' }, ] as const; /** How many sensor names are offered to click, not to fill a page of a large server. */ const SUGGESTED_SENSORS = 40; -function Card({ title, children }: { title: string; children: ReactNode }) { +/** A part of the page, as on Load Data: a title and a sentence on the left, the content on the right. */ +function Row({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) { return ( -
-

{title}

-
{children}
+
+
+

{title}

+ {description &&

{description}

} +
+
{children}
); } -/** The command that makes an admin token, and the way to give it to the UI. */ -function NeedAdmin({ signedIn }: { signedIn: boolean }) { - const openDialog = useAuthStore((state) => state.openDialog); +const code = (text: string) => {text}; + +function SensorsField({ + sensors, + onChange, + offered, +}: { + sensors: string[]; + onChange: (sensors: string[]) => void; + /** Names of the sensors of the server, to click in */ + offered: string[]; +}) { + const [draft, setDraft] = useState(''); + + function add() { + onChange(addSensor(sensors, draft)); + setDraft(''); + } + + const suggestions = offered.filter((name) => !sensors.includes(name)); return ( - -

- {signedIn ? 'The token in use does not have the ' : 'Making a token needs a token with the '} - admin scope. It gives nothing else: it cannot read nor write data. Make one where SensApp runs, with the secret: -

- -

- An admin token lasts an hour unless asked otherwise; make another when it expires. On a local run without a secret, the token SensApp - prints when it starts is one. -

-
-
-
- ); -} -function AuthenticationDisabled() { - return ( - -

- This server answers everyone, so nothing needs a token. To require tokens, give SensApp a secret with{' '} - SENSAPP_JWT_SECRET (sensapp generate-secret makes one) and remove{' '} - SENSAPP_AUTH_DISABLED. -

-
- ); -} + {sensors.length > 0 && ( +
    + {sensors.map((name) => ( +
  • + {name} + +
  • + ))} +
+ )} -function SensorChoices({ names, chosen, onAdd }: { names: string[]; chosen: string[]; onAdd: (name: string) => void }) { - const offered = names.filter((name) => !chosen.includes(name)).slice(0, SUGGESTED_SENSORS); - if (offered.length === 0) return null; - return ( -
- {offered.map((name) => ( - - ))} - {names.length > SUGGESTED_SENSORS && and {names.length - SUGGESTED_SENSORS} more} + {suggestions.length > 0 && ( +
+ {suggestions.slice(0, SUGGESTED_SENSORS).map((name) => ( + + ))} + {suggestions.length > SUGGESTED_SENSORS && ( + and {suggestions.length - SUGGESTED_SENSORS} more + )} +
+ )}
); } -function CreatedTokenView({ created, onAnother }: { created: CreatedToken; onAnother: () => void }) { +function CreatedTokenRow({ created, onDone }: { created: CreatedToken; onDone: () => void }) { const [copied, setCopied] = useState(null); async function handleCopy() { @@ -97,11 +130,7 @@ function CreatedTokenView({ created, onAnother }: { created: CreatedToken; onAno } return ( - -
- This is the only time the token is shown: SensApp does not keep it. Copy it now. -
- +
Scope
{created.scope.join(', ')}
Sensors
-
{created.sensors?.length ? created.sensors.join(', ') : 'all'}
+
{created.sensors?.length ? created.sensors.join(' · ') : 'all'}
Expires
{new Date(created.expires_at * 1000).toLocaleString()}
Id
@@ -129,30 +158,44 @@ function CreatedTokenView({ created, onAnother }: { created: CreatedToken; onAno

- Clients take it as Authorization: Bearer. The code of{' '} - + Clients send it as {code('Authorization: Bearer')}. The code of{' '} + Load Data {' '} - reads it from SENSAPP_TOKEN: + reads it from {code('SENSAPP_TOKEN')}:

-
- + ); } -/** `canRead`: the token may read the catalog, which gives the sensors to choose from. An admin token alone cannot. */ -function TokenForm({ canRead }: { canRead: boolean }) { +/** Where a token is made when this page cannot: the command line, with the secret. */ +function AuthenticationDisabled() { + return ( + +

+ To require tokens, give SensApp a secret with {code('SENSAPP_JWT_SECRET')} ({code('sensapp generate-secret')} makes one) and remove{' '} + {code('SENSAPP_AUTH_DISABLED')}. +

+
+ ); +} + +/** What the page offers: the form, the command that makes the same token, and the button when the token in use is an admin's. */ +function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead: boolean; isAdmin: boolean }) { const metrics = useMetrics(undefined, { enabled: canRead }); + const openDialog = useAuthStore((state) => state.openDialog); + const [subject, setSubject] = useState(''); + const [durationSeconds, setDuration] = useState(DAY); const [scope, setScope] = useState(['read', 'write']); - const [sensorsText, setSensorsText] = useState(''); - const [duration, setDuration] = useState(DAY); + const [sensors, setSensors] = useState([]); const mutation = useMutation({ mutationFn: async (body: Parameters[0]['body']) => unwrap(await createToken({ body })), @@ -160,12 +203,10 @@ function TokenForm({ canRead }: { canRead: boolean }) { gcTime: 0, }); - const sensors = parseSensorNames(sensorsText); + const wish = { subject, scope, sensors, durationSeconds }; const names = (metrics.data?.['dcat:dataset'] ?? []).map((dataset) => dataset['dct:title']); - - if (mutation.isSuccess) { - return mutation.reset()} />; - } + const wantsAdmin = scope.includes('admin'); + const ready = subject.trim() !== '' && scope.length > 0; function toggle(id: string) { setScope((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id])); @@ -173,34 +214,52 @@ function TokenForm({ canRead }: { canRead: boolean }) { function handleSubmit(event: FormEvent) { event.preventDefault(); - mutation.mutate({ - subject: subject.trim(), - scope, - sensors: sensors.length > 0 ? sensors : null, - duration_seconds: duration, - }); + if (!ready || wantsAdmin) return; + mutation.mutate({ subject: subject.trim(), scope, sensors: sensors.length > 0 ? sensors : null, duration_seconds: durationSeconds }); } + const refused = mutation.error instanceof ApiError && mutation.error.isAuthError; + return ( - -
- + + {mutation.isSuccess && mutation.reset()} />} + + + setSubject(event.target.value)} + /> + + +
+ {DURATIONS.map((item) => ( + setDuration(item.seconds)} + /> + ))} +
+
+ +
- What it may do + Scopes {SCOPES.map((item) => ( ))}
+
-
- - setSensorsText((text) => (text.trim() === '' ? name : `${text.trim().replace(/,$/, '')}, ${name}`))} - /> -
+ + + - - - {mutation.isError && !(mutation.error instanceof ApiError && mutation.error.isAuthError) && ( -
- {mutation.error instanceof Error ? mutation.error.message : 'The token was not made'} -
- )} + + The same token, made where SensApp runs: it needs the secret. It is the only way to make an {code('admin')} token. In a container, put{' '} + {code('docker exec ')} in front, on Kubernetes {code('kubectl exec deploy/ --')}. + + } + > + + -
- -
- -
+ Needs a token with the {code('admin')} scope. It reads and writes nothing.} + > + {isAdmin ? ( + <> + {mutation.isError && !refused && ( +
+ {mutation.error instanceof Error ? mutation.error.message : 'The token was not made'} +
+ )} + {wantsAdmin &&

An admin token is only made with the command line.

} +
+ +
+ + ) : ( + <> +

+ {token !== null ? 'The token in use does not have it. ' : ''}Make an admin token with the command line, it lasts an hour: +

+ +
+ +
+ + )} +
+ ); } @@ -265,25 +326,24 @@ function TokenForm({ canRead }: { canRead: boolean }) { export function CredentialsPage() { const token = useAuthStore((state) => state.token); // Without a token, the catalog tells whether the server asks for one. With one, it is not - // asked: an admin token cannot read, and the refusal would ask for a token again. - const metrics = useMetrics(undefined, { enabled: token === null }); + // asked here: an admin token cannot read, and the refusal would ask for a token again. + const probe = useMetrics(undefined, { enabled: token === null }); const scopes = token === null ? [] : tokenScopes(describeToken(token)); - const isAdmin = scopes.includes('admin'); - - let content; - if (isAdmin) { - content = ; - } else if (token !== null) { - content = ; - } else if (metrics.isPending) { + + let content: ReactNode; + if (token === null && probe.isPending) { content = ; - } else if (metrics.isSuccess) { + } else if (token === null && probe.isSuccess) { content = ; } else { - content = ; + content = ; } - return
{content}
; + return ( +
+ {content} +
+ ); } export default CredentialsPage; diff --git a/frontend/src/pages/LoadPage.test.tsx b/frontend/src/pages/LoadPage.test.tsx index 9763b4f1..7fbd306d 100644 --- a/frontend/src/pages/LoadPage.test.tsx +++ b/frontend/src/pages/LoadPage.test.tsx @@ -94,11 +94,26 @@ describe('LoadPage', () => { expect(await screen.findByRole('button', { name: 'Copy SenML JSON' })).toHaveTextContent('Copied'); }); - it('says how to make a token when the server asks for one', async () => { + it('says how to make a token when the server asks for one, and links to where it is made', async () => { + const user = userEvent.setup(); useAuthStore.setState({ token: null, authRequired: true }); renderAt('/load'); await screen.findByRole('tabpanel'); expect(screen.getByRole('tabpanel')).toHaveTextContent('sensapp generate-token me --scope write'); expect(code()).toContain('os.environ["SENSAPP_TOKEN"]'); + + // Every way starts with the same section + for (const way of ['Telegraf', 'Prometheus', 'curl']) { + await user.click(screen.getByRole('tab', { name: way })); + expect(screen.getByRole('heading', { name: 'A token' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Make a token in Credentials' })).toHaveAttribute('href', '/credentials'); + } + }); + + it('says nothing of tokens when the server is open', async () => { + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.queryByRole('link', { name: 'Make a token in Credentials' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'A token' })).toBeNull(); }); }); diff --git a/frontend/src/pages/LoadPage.tsx b/frontend/src/pages/LoadPage.tsx index 0a667c73..78fe61c2 100644 --- a/frontend/src/pages/LoadPage.tsx +++ b/frontend/src/pages/LoadPage.tsx @@ -1,8 +1,8 @@ import { Fragment, useState } from 'react'; import type { KeyboardEvent, ReactNode } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { CodeBlock } from '../components/CodeBlock'; -import { LOAD_WAYS, WRITE_TOKEN_COMMAND, sectionsFor } from '../lib/loadSnippets'; +import { LOAD_WAYS, sectionsFor } from '../lib/loadSnippets'; import type { LoadWay } from '../lib/loadSnippets'; import { useAuthStore } from '../stores/useAuthStore'; @@ -78,18 +78,17 @@ export function LoadPage() {
- {authenticated && ( -

- This server asks for a token. Loading needs the write scope: make one with{' '} - {WRITE_TOKEN_COMMAND}. The code reads it from SENSAPP_TOKEN. -

- )}
{sections.map((section) => (

{section.title}

{withCode(section.description)}

+ {section.link && ( + + {section.link.label} + + )}
From 8aab6967ef947466ac1872f64eaf8178d97e0fa0 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:07:27 +0200 Subject: [PATCH 43/53] =?UTF-8?q?feat:=20=E2=9C=A8=20auth:=20the=20suggest?= =?UTF-8?q?ed=20admin=20token=20also=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command suggested for an admin token (sign-in dialog of Credentials, the chart notes, the docs) is now `--scope read,admin`: the admin scope reads nothing, so a token with only it signed in and the explorer asked for another token. The scopes stay separate on the server, and the Credentials page reads the catalog when the token can read. Co-Authored-By: Claude Sonnet 5.5 --- charts/sensapp/README.md | 2 +- charts/sensapp/templates/NOTES.txt | 2 +- charts/sensapp/values.yaml | 2 +- docs/FRONTEND.md | 4 ++-- docs/JWT_AUTH.md | 6 +++--- frontend/src/lib/credentials.test.ts | 8 +++++++- frontend/src/lib/credentials.ts | 8 ++++++-- frontend/src/pages/CredentialsPage.test.tsx | 21 ++++++++++++++++++--- frontend/src/pages/CredentialsPage.tsx | 8 +++++++- 9 files changed, 46 insertions(+), 15 deletions(-) diff --git a/charts/sensapp/README.md b/charts/sensapp/README.md index 7d82050c..fb91c538 100644 --- a/charts/sensapp/README.md +++ b/charts/sensapp/README.md @@ -58,7 +58,7 @@ For a quick setup, you can set `storage.connectionString` directly instead of re SensApp does not run open: the container image listens on every address, so it refuses to start without a secret. By default the chart **makes a random secret on the first install** and keeps it across upgrades (it reads it back from the Secret of the release). Make an admin token, which makes the other tokens in the UI (Credentials tab): ```bash -kubectl exec deploy/sensapp -- sensapp generate-token me --scope admin +kubectl exec deploy/sensapp -- sensapp generate-token me --scope read,admin ``` | Value | Effect | diff --git a/charts/sensapp/templates/NOTES.txt b/charts/sensapp/templates/NOTES.txt index 6ecccad6..c2e2a3d6 100644 --- a/charts/sensapp/templates/NOTES.txt +++ b/charts/sensapp/templates/NOTES.txt @@ -27,7 +27,7 @@ The chart made the secret, and keeps it across upgrades. {{- end }} Make an admin token (it makes the other tokens, in the UI under Credentials or with POST /api/v1/admin/tokens): - kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token me --scope admin + kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token me --scope read,admin Or a token for a client: kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token --scope readwrite --duration 86400 Use it in requests: diff --git a/charts/sensapp/values.yaml b/charts/sensapp/values.yaml index bd04d183..0b17158e 100644 --- a/charts/sensapp/values.yaml +++ b/charts/sensapp/values.yaml @@ -91,7 +91,7 @@ storage: # # By default the chart makes a random secret on the first install and keeps it across upgrades # (it is read back from the Secret of the release). Make an admin token with: -# kubectl exec deploy/ -- sensapp generate-token me --scope admin +# kubectl exec deploy/ -- sensapp generate-token me --scope read,admin # Tools that only render the chart (Argo CD, `helm template`) cannot read it back and would make a # new secret each time: for those, give jwtSecret or existingSecret. auth: diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 022e362c..2ed085ff 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -65,11 +65,11 @@ The third tab (`/ui/credentials`) makes tokens for the clients of SensApp (see [ 3. **What it may do**: `read` and `write` by default, `delete` on demand, and `admin`, which only the command line makes. 4. **Only these sensors**: type a name and press Enter (or Add), remove it with its ×. A name is kept exactly as typed, spaces and commas included, and the sensors of the server are offered to click when the token in use can read them (an admin-only token cannot). 5. **Command line**: the `sensapp generate-token …` command for what the form says, updated as it changes, with a `--sensor` for each name and everything quoted for the shell. It works without an admin token, needs the secret (so it runs where SensApp runs: `docker exec`, `kubectl exec`), and is the only way to make an admin token. -6. **Make it here**: the button, for an admin token (`POST /api/v1/admin/tokens`). Without one the section says how to make one and opens the sign-in dialog, which then suggests the admin command instead of a read token. +6. **Make it here**: the button, for an admin token (`POST /api/v1/admin/tokens`). Without one the section says how to make one and opens the sign-in dialog, which then suggests the admin command (`sensapp generate-token me --scope read,admin`: `read` too, so that the same token browses the data and the explorer does not ask for another) instead of a read token. The token is then **shown once** at the top of the page, with a Copy button, what it was made with and the `export SENSAPP_TOKEN=…` line that [Load Data](#load-data) reads. SensApp keeps nothing: the page has no list of tokens and cannot revoke one (rotating the secret does, see [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens)). The page does not keep the token either: it is dropped from the cache of the requests when the page is left, and a refresh forgets it. -With no token, a server that answers anyway has authentication disabled: the page says so and shows nothing else. With an admin token the catalog is not read, because an admin token cannot read and the refusal would ask for a token again. +With no token, a server that answers anyway has authentication disabled: the page says so and shows nothing else. The catalog (for the sensors to click in) is only read when the token in use can read: an `admin` token alone cannot, and the refusal would ask for a token again. The page is in `src/pages/CredentialsPage.tsx` (the command and the checks of a name in `src/lib/credentials.ts`) and loaded when its tab is first opened (it shows code, with the highlighter). diff --git a/docs/JWT_AUTH.md b/docs/JWT_AUTH.md index 20a8b513..16aad889 100644 --- a/docs/JWT_AUTH.md +++ b/docs/JWT_AUTH.md @@ -55,8 +55,8 @@ sensapp generate-token cleanup --scope delete --duration 900 # Scopes can be combined sensapp generate-token cleanup --scope readwrite,delete -# A token that makes tokens -sensapp generate-token me --scope admin +# A token that makes tokens, and that reads, to browse the data of the UI with it +sensapp generate-token me --scope read,admin ``` The token is printed to stdout. In a container: `docker exec sensapp generate-token …`, in Kubernetes `kubectl exec deploy/ -- sensapp generate-token …`. @@ -83,7 +83,7 @@ The answer holds the `token` and what it was made with (`subject`, `scope`, `sen - `scope` is a list of `read`, `write` and `delete`. **The endpoint never makes an `admin` token** (403): those come from the command line, which has the secret. A stolen admin token therefore cannot renew itself, and an admin token that expires is replaced with one command. - `duration_seconds` is at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (one year by default). -- An admin token reads and writes nothing: the scope is as separate as `delete` is. Whoever has it can still make a token for any scope, so keep it as private as the secret, and short. It lasts an hour by default. +- The `admin` scope reads and writes nothing: it is as separate as `delete` is. The command the UI suggests asks for `read,admin`, so that the same token can browse the data and the web UI does not ask for another token. Make it `admin` alone for a token that only manages tokens. Whoever has it can still make a token for any scope, so keep it as private as the secret, and short. It lasts an hour by default. - With authentication disabled there is nothing to sign with: the answer is `404`. - Every creation is logged (who asked, for whom, the scope, the sensors, the expiry, the `jti`), never the token. diff --git a/frontend/src/lib/credentials.test.ts b/frontend/src/lib/credentials.test.ts index 1b50244f..ee2f34be 100644 --- a/frontend/src/lib/credentials.test.ts +++ b/frontend/src/lib/credentials.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { addSensor, shellWord, tokenCommand, tokenScopes } from './credentials'; +import { ADMIN_TOKEN_COMMAND, addSensor, shellWord, tokenCommand, tokenScopes } from './credentials'; describe('addSensor', () => { it('adds a name and keeps the order', () => { @@ -44,6 +44,12 @@ describe('shellWord', () => { }); }); +describe('ADMIN_TOKEN_COMMAND', () => { + it('makes a token that can read as well, so that the explorer does not ask for another', () => { + expect(ADMIN_TOKEN_COMMAND).toBe('sensapp generate-token me --scope read,admin'); + }); +}); + describe('tokenCommand', () => { const wish = { subject: 'edge-7', scope: ['read', 'write'], sensors: [], durationSeconds: 86400 }; diff --git a/frontend/src/lib/credentials.ts b/frontend/src/lib/credentials.ts index 33f0ef60..536ab86b 100644 --- a/frontend/src/lib/credentials.ts +++ b/frontend/src/lib/credentials.ts @@ -1,7 +1,11 @@ import type { TokenInfo } from '../stores/useAuthStore'; -/** What makes a token that can make tokens. It reads nothing, and the endpoint never makes another one. */ -export const ADMIN_TOKEN_COMMAND = 'sensapp generate-token me --scope admin'; +/** + * What makes a token that can make tokens. The `admin` scope reads nothing, and the endpoint never + * makes another admin token. `read` is added for the person who signs in with it: the explorer + * would otherwise ask for another token. + */ +export const ADMIN_TOKEN_COMMAND = 'sensapp generate-token me --scope read,admin'; /** What the form asks for. */ export interface TokenWish { diff --git a/frontend/src/pages/CredentialsPage.test.tsx b/frontend/src/pages/CredentialsPage.test.tsx index f28b6777..b437ccfa 100644 --- a/frontend/src/pages/CredentialsPage.test.tsx +++ b/frontend/src/pages/CredentialsPage.test.tsx @@ -18,6 +18,7 @@ vi.mock('../client', () => ({ })); const ADMIN = fakeToken({ sub: 'root', scope: 'admin', exp: 4_102_444_800 }); +const READ_ADMIN = fakeToken({ sub: 'me', scope: 'read admin', exp: 4_102_444_800 }); const READER = fakeToken({ sub: 'reader', scope: 'read', exp: 4_102_444_800 }); // The token the local run prints has every scope const LOCAL = fakeToken({ sub: 'local-dev', scope: 'read write delete admin', exp: 4_102_444_800 }); @@ -85,12 +86,13 @@ describe('CredentialsPage', () => { renderPage(); expect(await screen.findByRole('textbox', { name: 'Name' })).toBeInTheDocument(); expect(command()).toContain('sensapp generate-token NAME --scope read,write --duration 86400'); - expect(screen.getByLabelText('Command that makes an admin token')).toHaveTextContent('sensapp generate-token me --scope admin'); + expect(screen.getByLabelText('Command that makes an admin token')).toHaveTextContent('sensapp generate-token me --scope read,admin'); expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); // The sign-in dialog opened with the answer of the server, and the command of an admin token const dialog = await screen.findByRole('dialog'); - expect(dialog).toHaveTextContent('sensapp generate-token me --scope admin'); - expect(dialog).not.toHaveTextContent('--scope read'); + expect(dialog).toHaveTextContent('sensapp generate-token me --scope read,admin'); + // Not the command of the read token that the other pages suggest + expect(dialog).not.toHaveTextContent('generate-token ui --scope read'); }); it('says that the token in use is not an admin one, and leaves the command', async () => { @@ -109,6 +111,19 @@ describe('CredentialsPage', () => { expect(useAuthStore.getState().dialogOpen).toBe(false); }); + it('reads the sensors with the token the page suggests, which has read and admin: nothing asks for another token', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: READ_ADMIN }); + mockCreateToken.mockResolvedValue({ data: created }); + renderPage(); + + // The form is the admin's, and the sensors of the server are offered + await user.click(await screen.findByRole('button', { name: 'temperature' })); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeInTheDocument(); + expect(mockListMetrics).toHaveBeenCalled(); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); + it('writes the command as the form is filled, quoted, with a --sensor for each name', async () => { const user = userEvent.setup(); useAuthStore.setState({ token: ADMIN }); diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx index 3592415e..e8245571 100644 --- a/frontend/src/pages/CredentialsPage.tsx +++ b/frontend/src/pages/CredentialsPage.tsx @@ -288,7 +288,13 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead Needs a token with the {code('admin')} scope. It reads and writes nothing.} + description={ + isAdmin ? ( + 'The token is shown once, at the top of the page.' + ) : ( + <>Needs a token with the {code('admin')} scope, which gives nothing else: the command has {code('read')} too, to browse the data with it. + ) + } > {isAdmin ? ( <> From db26817c7d87e98b2d0e21b424c3b7f9acec1a1f Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:08:53 +0200 Subject: [PATCH 44/53] =?UTF-8?q?fix:=20=F0=9F=90=9B=20frontend:=20the=20s?= =?UTF-8?q?cope=20checkboxes=20of=20Credentials=20are=20the=20blue=20of=20?= =?UTF-8?q?the=20others?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were the neutral grey of daisyUI, unlike the checkbox of the series table. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/pages/CredentialsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx index e8245571..39e33487 100644 --- a/frontend/src/pages/CredentialsPage.tsx +++ b/frontend/src/pages/CredentialsPage.tsx @@ -262,7 +262,7 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead Scopes {SCOPES.map((item) => ( From d8b81210bd3124008cf432a820cb9b5b190a3e5b Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:21:42 +0200 Subject: [PATCH 45/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20a=20dice?= =?UTF-8?q?=20for=20the=20name=20of=20a=20token,=20and=20the=20token=20und?= =?UTF-8?q?er=20the=20button=20that=20makes=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name comes from the words of the names of the containers of Docker. Make it here comes before the command line, and the token is shown below the button, not at the top of the page. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/lib/names.test.ts | 19 ++++++ frontend/src/lib/names.ts | 50 ++++++++++++++ frontend/src/pages/CredentialsPage.test.tsx | 18 ++++- frontend/src/pages/CredentialsPage.tsx | 73 +++++++++++---------- 4 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/names.test.ts create mode 100644 frontend/src/lib/names.ts diff --git a/frontend/src/lib/names.test.ts b/frontend/src/lib/names.test.ts new file mode 100644 index 00000000..2f58d76d --- /dev/null +++ b/frontend/src/lib/names.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { randomName } from './names'; + +describe('randomName', () => { + it('is two adjectives, a scientist and a number, joined by dashes', () => { + for (let i = 0; i < 200; i++) expect(randomName()).toMatch(/^[a-z]+-[a-z]+-[a-z]+-\d{1,2}$/); + }); + + it('is made of what the random numbers pick', () => { + expect(randomName(() => 0)).toBe('admiring-adoring-agnesi-0'); + }); + + it('does not repeat an adjective', () => { + for (let i = 0; i < 200; i++) { + const [first, second] = randomName().split('-'); + expect(first).not.toBe(second); + } + }); +}); diff --git a/frontend/src/lib/names.ts b/frontend/src/lib/names.ts new file mode 100644 index 00000000..aeb157e3 --- /dev/null +++ b/frontend/src/lib/names.ts @@ -0,0 +1,50 @@ +/** + * Names for a token, as Docker makes them for its containers: two adjectives, a scientist and a number. + * The words are those of the list of moby (internal/namesgenerator), Apache 2.0. + */ + +const ADJECTIVES = [ + 'admiring', 'adoring', 'affectionate', 'agitated', 'amazing', 'angry', 'awesome', 'beautiful', 'blissful', 'bold', 'boring', 'brave', + 'busy', 'charming', 'clever', 'compassionate', 'competent', 'condescending', 'confident', 'cool', 'cranky', 'crazy', 'dazzling', + 'determined', 'distracted', 'dreamy', 'eager', 'ecstatic', 'elastic', 'elated', 'elegant', 'eloquent', 'epic', 'exciting', 'fervent', + 'festive', 'flamboyant', 'focused', 'friendly', 'frosty', 'funny', 'gallant', 'gifted', 'goofy', 'gracious', 'great', 'happy', 'hardcore', + 'heuristic', 'hopeful', 'hungry', 'infallible', 'inspiring', 'intelligent', 'interesting', 'jolly', 'jovial', 'keen', 'kind', 'laughing', + 'loving', 'lucid', 'magical', 'modest', 'musing', 'mystifying', 'naughty', 'nervous', 'nice', 'nifty', 'nostalgic', 'objective', + 'optimistic', 'peaceful', 'pedantic', 'pensive', 'practical', 'priceless', 'quirky', 'quizzical', 'recursing', 'relaxed', 'reverent', + 'romantic', 'sad', 'serene', 'sharp', 'silly', 'sleepy', 'stoic', 'strange', 'stupefied', 'suspicious', 'sweet', 'tender', 'thirsty', + 'trusting', 'unruffled', 'upbeat', 'vibrant', 'vigilant', 'vigorous', 'wizardly', 'wonderful', 'xenodochial', 'youthful', 'zealous', 'zen', +]; + +const NOUNS = [ + 'agnesi', 'albattani', 'allen', 'almeida', 'antonelli', 'archimedes', 'ardinghelli', 'aryabhata', 'austin', 'babbage', 'banach', 'banzai', + 'bardeen', 'bartik', 'bassi', 'beaver', 'bell', 'benz', 'bhabha', 'bhaskara', 'black', 'blackburn', 'blackwell', 'bohr', 'booth', 'borg', + 'bose', 'bouman', 'boyd', 'brahmagupta', 'brattain', 'brown', 'buck', 'burnell', 'cannon', 'carson', 'cartwright', 'carver', 'cerf', + 'chandrasekhar', 'chaplygin', 'chatelet', 'chatterjee', 'chaum', 'chebyshev', 'clarke', 'cohen', 'colden', 'cori', 'cray', 'curie', + 'curran', 'darwin', 'davinci', 'dewdney', 'dhawan', 'diffie', 'dijkstra', 'dirac', 'driscoll', 'dubinsky', 'easley', 'edison', 'einstein', + 'elbakyan', 'elgamal', 'elion', 'ellis', 'engelbart', 'euclid', 'euler', 'faraday', 'feistel', 'fermat', 'fermi', 'feynman', 'franklin', + 'gagarin', 'galileo', 'galois', 'ganguly', 'gates', 'gauss', 'germain', 'goldberg', 'goldstine', 'goldwasser', 'golick', 'goodall', + 'gould', 'greider', 'grothendieck', 'haibt', 'hamilton', 'haslett', 'hawking', 'heisenberg', 'hellman', 'hermann', 'herschel', 'hertz', + 'heyrovsky', 'hodgkin', 'hofstadter', 'hoover', 'hopper', 'hugle', 'hypatia', 'ishizaka', 'jackson', 'jang', 'jemison', 'jennings', + 'jepsen', 'johnson', 'joliot', 'jones', 'kalam', 'kapitsa', 'kare', 'keldysh', 'keller', 'kepler', 'khayyam', 'khorana', 'kilby', 'kirch', + 'knuth', 'kowalevski', 'lalande', 'lamarr', 'lamport', 'leakey', 'leavitt', 'lederberg', 'lehmann', 'lewin', 'lichterman', 'liskov', + 'lovelace', 'lumiere', 'mahavira', 'margulis', 'matsumoto', 'maxwell', 'mayer', 'mccarthy', 'mcclintock', 'mclaren', 'mclean', 'mcnulty', + 'meitner', 'mendel', 'mendeleev', 'meninsky', 'merkle', 'mestorf', 'mirzakhani', 'montalcini', 'moore', 'morse', 'moser', 'murdock', + 'napier', 'nash', 'neumann', 'newton', 'nightingale', 'nobel', 'noether', 'northcutt', 'noyce', 'panini', 'pare', 'pascal', 'pasteur', + 'payne', 'perlman', 'pike', 'poincare', 'poitras', 'proskuriakova', 'ptolemy', 'raman', 'ramanujan', 'rhodes', 'ride', 'ritchie', + 'robinson', 'roentgen', 'rosalind', 'rubin', 'saha', 'sammet', 'sanderson', 'satoshi', 'shamir', 'shannon', 'shaw', 'shirley', 'shockley', + 'shtern', 'sinoussi', 'snyder', 'solomon', 'spence', 'stonebraker', 'sutherland', 'swanson', 'swartz', 'swirles', 'taussig', 'tesla', + 'tharp', 'thompson', 'torvalds', 'tu', 'turing', 'varahamihira', 'vaughan', 'villani', 'visvesvaraya', 'volhard', 'wescoff', 'wilbur', + 'wiles', 'williams', 'williamson', 'wilson', 'wing', 'wozniak', 'wright', 'wu', 'yalow', 'yonath', 'zhukovsky', +]; + +const pick = (words: readonly string[], random: () => number) => words[Math.floor(random() * words.length)]; + +/** `brave-quirky-curie-42`. A name is never made of the same adjective twice. */ +export function randomName(random: () => number = Math.random): string { + const first = pick(ADJECTIVES, random); + const second = pick( + ADJECTIVES.filter((word) => word !== first), + random, + ); + return `${first}-${second}-${pick(NOUNS, random)}-${Math.floor(random() * 100)}`; +} diff --git a/frontend/src/pages/CredentialsPage.test.tsx b/frontend/src/pages/CredentialsPage.test.tsx index b437ccfa..75463847 100644 --- a/frontend/src/pages/CredentialsPage.test.tsx +++ b/frontend/src/pages/CredentialsPage.test.tsx @@ -78,7 +78,7 @@ describe('CredentialsPage', () => { renderPage(); await screen.findByRole('textbox', { name: 'Name' }); const titles = screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent); - expect(titles).toEqual(['Name', 'Valid for', 'What it may do', 'Only these sensors', 'Command line', 'Make it here']); + expect(titles).toEqual(['Name', 'Valid for', 'What it may do', 'Only these sensors', 'Make it here', 'Command line']); }); it('shows the form and the command, and how to get an admin token, to a server that wants a token', async () => { @@ -141,6 +141,18 @@ describe('CredentialsPage', () => { ); }); + it('fills the name with a random one, to click again for another', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + const name = await screen.findByRole('textbox', { name: 'Name' }); + await user.click(screen.getByRole('button', { name: 'Random name' })); + expect((name as HTMLInputElement).value).toMatch(/^[a-z]+-[a-z]+-[a-z]+-\d{1,2}$/); + expect(command()).toContain(`generate-token ${(name as HTMLInputElement).value} `); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeEnabled(); + }); + it('makes an admin token with the command only', async () => { const user = userEvent.setup(); useAuthStore.setState({ token: ADMIN }); @@ -177,7 +189,9 @@ describe('CredentialsPage', () => { body: { subject: 'edge-7', scope: ['read', 'write'], sensors: ['temperature', 'cpu,usage'], duration_seconds: 3600 }, }); - expect(await screen.findByText(/only time it is shown/)).toBeInTheDocument(); + expect(await screen.findByText(/does not keep tokens/)).toBeInTheDocument(); + // The token is under the button, before the command line + expect(submit.compareDocumentPosition(screen.getByRole('textbox', { name: 'Token' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByRole('textbox', { name: 'Token' })).toHaveValue('made.by.sensapp'); expect(screen.getByLabelText('Export of the token')).toHaveTextContent('export SENSAPP_TOKEN=made.by.sensapp'); expect(screen.getByText(created.jti)).toBeInTheDocument(); diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx index 39e33487..530d8302 100644 --- a/frontend/src/pages/CredentialsPage.tsx +++ b/frontend/src/pages/CredentialsPage.tsx @@ -10,6 +10,7 @@ import { Loading } from '../components/Feedback'; import { useMetrics } from '../hooks/useMetrics'; import { copyText } from '../lib/copyText'; import { ADMIN_TOKEN_COMMAND, addSensor, tokenCommand, tokenScopes } from '../lib/credentials'; +import { randomName } from '../lib/names'; import { describeToken, useAuthStore } from '../stores/useAuthStore'; const DAY = 24 * 3600; @@ -121,7 +122,8 @@ function SensorsField({ ); } -function CreatedTokenRow({ created, onDone }: { created: CreatedToken; onDone: () => void }) { +/** The token that was just made, under the button that made it. */ +function CreatedTokenResult({ created, onDone }: { created: CreatedToken; onDone: () => void }) { const [copied, setCopied] = useState(null); async function handleCopy() { @@ -130,7 +132,7 @@ function CreatedTokenRow({ created, onDone }: { created: CreatedToken; onDone: ( } return ( - +
+

SensApp does not keep tokens, copy it now.

For
@@ -171,7 +174,7 @@ function CreatedTokenRow({ created, onDone }: { created: CreatedToken; onDone: ( Done
-
+
); } @@ -222,20 +225,23 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead return (
- {mutation.isSuccess && mutation.reset()} />} - - setSubject(event.target.value)} - /> +
+ setSubject(event.target.value)} + /> + +
- - The same token, made where SensApp runs: it needs the secret. It is the only way to make an {code('admin')} token. In a container, put{' '} - {code('docker exec ')} in front, on Kubernetes {code('kubectl exec deploy/ --')}. - - } - > - - - Needs a token with the {code('admin')} scope, which gives nothing else: the command has {code('read')} too, to browse the data with it. - ) - } + isAdmin ? undefined : ( + <>Needs a token with the {code('admin')} scope, which gives nothing else: the command has {code('read')} too, to browse the data with it. + ) + } > {isAdmin ? ( <> @@ -309,6 +301,7 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead {mutation.isPending ? 'Making…' : 'Make the token'}
+ {mutation.isSuccess && mutation.reset()} />} ) : ( <> @@ -324,6 +317,18 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead )}
+ + + The same token, made where SensApp runs: it needs the secret. It is the only way to make an {code('admin')} token. In a container, put{' '} + {code('docker exec ')} in front, on Kubernetes {code('kubectl exec deploy/ --')}. + + } + > + + ); } From 3c3bde801ee133ed2a54ada932566691b02edeb7 Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:37:59 +0200 Subject: [PATCH 46/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20two?= =?UTF-8?q?=20ways=20to=20copy=20a=20token=20look=20the=20same=20and=20com?= =?UTF-8?q?e=20together,=20before=20its=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/pages/CredentialsPage.test.tsx | 10 +++---- frontend/src/pages/CredentialsPage.tsx | 32 ++------------------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/frontend/src/pages/CredentialsPage.test.tsx b/frontend/src/pages/CredentialsPage.test.tsx index 75463847..f923f9d7 100644 --- a/frontend/src/pages/CredentialsPage.test.tsx +++ b/frontend/src/pages/CredentialsPage.test.tsx @@ -191,17 +191,17 @@ describe('CredentialsPage', () => { expect(await screen.findByText(/does not keep tokens/)).toBeInTheDocument(); // The token is under the button, before the command line - expect(submit.compareDocumentPosition(screen.getByRole('textbox', { name: 'Token' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(screen.getByRole('textbox', { name: 'Token' })).toHaveValue('made.by.sensapp'); + expect(submit.compareDocumentPosition(screen.getByLabelText('Token')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByLabelText('Token')).toHaveTextContent('made.by.sensapp'); expect(screen.getByLabelText('Export of the token')).toHaveTextContent('export SENSAPP_TOKEN=made.by.sensapp'); expect(screen.getByText(created.jti)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Copy' })); - expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Copy Token' })); + expect(await screen.findByText('Copied to the clipboard')).toBeInTheDocument(); expect(await navigator.clipboard.readText()).toBe('made.by.sensapp'); await user.click(screen.getByRole('button', { name: 'Done' })); - await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Token' })).toBeNull()); + await waitFor(() => expect(screen.queryByLabelText('Token')).toBeNull()); // The form is still there, with what was typed expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(' edge-7 '); }); diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx index 530d8302..ce46f39a 100644 --- a/frontend/src/pages/CredentialsPage.tsx +++ b/frontend/src/pages/CredentialsPage.tsx @@ -1,14 +1,12 @@ import { useState } from 'react'; import type { FormEvent, ReactNode } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; import { createToken } from '../client'; import type { CreatedToken } from '../client'; import { ApiError, unwrap } from '../api/clientConfig'; import { CodeBlock } from '../components/CodeBlock'; import { Loading } from '../components/Feedback'; import { useMetrics } from '../hooks/useMetrics'; -import { copyText } from '../lib/copyText'; import { ADMIN_TOKEN_COMMAND, addSensor, tokenCommand, tokenScopes } from '../lib/credentials'; import { randomName } from '../lib/names'; import { describeToken, useAuthStore } from '../stores/useAuthStore'; @@ -124,27 +122,10 @@ function SensorsField({ /** The token that was just made, under the button that made it. */ function CreatedTokenResult({ created, onDone }: { created: CreatedToken; onDone: () => void }) { - const [copied, setCopied] = useState(null); - - async function handleCopy() { - setCopied(await copyText(created.token)); - window.setTimeout(() => setCopied(null), 2000); - } - return (
-
- event.currentTarget.select()} - /> - -
+ +

SensApp does not keep tokens, copy it now.

@@ -160,15 +141,6 @@ function CreatedTokenResult({ created, onDone }: { created: CreatedToken; onDone
{created.jti}
-

- Clients send it as {code('Authorization: Bearer')}. The code of{' '} - - Load Data - {' '} - reads it from {code('SENSAPP_TOKEN')}: -

- -
+
+ +

SensApp does not keep tokens, copy it now.

@@ -140,12 +145,6 @@ function CreatedTokenResult({ created, onDone }: { created: CreatedToken; onDone
Id
{created.jti}
- -
- -
); } @@ -178,6 +177,10 @@ function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead gcTime: 0, }); + // A token made with other choices than the form now has would be confusing: it goes + const { reset } = mutation; + useEffect(() => reset(), [subject, durationSeconds, scope, sensors, reset]); + const wish = { subject, scope, sensors, durationSeconds }; const names = (metrics.data?.['dcat:dataset'] ?? []).map((dataset) => dataset['dct:title']); const wantsAdmin = scope.includes('admin'); From 65b9e45b97c5bcf5a7ec133a920b20d0105e49dc Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 14:44:22 +0200 Subject: [PATCH 48/53] =?UTF-8?q?feat:=20=E2=9C=A8=20frontend:=20the=20hea?= =?UTF-8?q?der=20is=20aligned=20on=20one=20line,=20the=20current=20tab=20r?= =?UTF-8?q?ests=20on=20the=20border?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tabs now run the full height of the bar so the line under the current one sits on the border of the header, a short divider replaces the full-height one, and Sign in / Sign out are the height of the Code and API Docs buttons. On a narrow screen the tabs scroll instead of widening the page. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/components/AuthStatus.tsx | 4 ++-- frontend/src/components/Layout.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx index ea9ad40e..5b4417b0 100644 --- a/frontend/src/components/AuthStatus.tsx +++ b/frontend/src/components/AuthStatus.tsx @@ -22,7 +22,7 @@ export function AuthStatus() { return (
{info?.subject ?? 'token'} -
@@ -31,7 +31,7 @@ export function AuthStatus() { if (authRequired) { return ( - ); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3c14a7e7..de5212d1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -23,8 +23,9 @@ export function Layout() {
-
-
+ {/* The row covers the border of the header, so the line under the current tab sits on it */} +
+
{/* The white logo on a dark page, the small one (no name) on a narrow screen */} @@ -34,7 +35,9 @@ export function Layout() { SensApp -
-
+
{explorer && } Date: Mon, 5 Oct 2026 14:47:45 +0200 Subject: [PATCH 49/53] =?UTF-8?q?fix:=20=F0=9F=90=9B=20frontend:=20the=20l?= =?UTF-8?q?ine=20under=20the=20current=20tab=20goes=20down=20to=20the=20bo?= =?UTF-8?q?ttom=20of=20the=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab strip was centered in the bar instead of stretched, so the line floated above the border. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/components/Layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index de5212d1..bdc8c3c3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -37,7 +37,7 @@ export function Layout() {
From 20bee2d2addea5d0c395321920a340701e1b647d Mon Sep 17 00:00:00 2001 From: Antoine Pultier Date: Mon, 5 Oct 2026 15:12:41 +0200 Subject: [PATCH 53/53] =?UTF-8?q?feat:=C2=A0=F0=9F=93=9D=20improve=20readm?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 ++++++++++++++++++-------------- docs/screenshot.png | Bin 0 -> 361031 bytes 2 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 docs/screenshot.png diff --git a/README.md b/README.md index b85ba905..e78d2ef2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![SensApp](./docs/sensapp_logo.png) -SensApp is an open-source sensor data platform developed by SINTEF. +SensApp is an open-source sensor data platform developed by [SINTEF](https://www.sintef.no/). It handles time-series data ingestion, storage, and retrieval. From small edge devices to big data digital twins, SensApp *may* be useful. @@ -10,22 +10,23 @@ SensApp is compatible with Prometheus and InfluxDB, but with an alternative arch Dealing with system statistics for the last 24 hours? InfluxDB or Prometheus are excellent choices. Fetching average bathroom temperatures over the last 10 years grouped by day? SensApp will compute that instantly while InfluxDB or Prometheus will take a little while. -But you don't have to chose, both InfluxDB and Prometheus can replicate their data to SensApp for long-term storage and analysis. So you get the best of both worlds. +But you don't have to chose, both InfluxDB and Prometheus can replicate their data to SensApp for long-term storage and analysis. So you can get the best of both worlds. -You can also use SensApp as a standalone time-series database. +You can also use SensApp as a standalone time-series database. It's designed to work from the edge to the big data workloads in the cloud, by relying on different databases. -## Quickstart +![GUI Screenshot](./docs/screenshot.png) -The quickest way to run SensApp is with SQLite so no external database is required. +## Quickstart -Start SensApp with SQLite: +The simplest way to test SensApp is using it with SQLite and no authentication: ```bash -SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db \ -cargo run +export SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db +export SENSAPP_AUTH_DISABLED=true +cargo run --release ``` -By default, SensApp listens on [http://127.0.0.1:3000](http://127.0.0.1:3000). +By default, SensApp listens on [http://127.0.0.1:3000](http://127.0.0.1:3000) for both its API and user interface. Ingest one sample: @@ -59,7 +60,7 @@ async def main(): asyncio.run(main()) ``` -### Using Containers +## Software Containers and Kubernetes ```bash docker compose up @@ -76,6 +77,7 @@ Published releases also provide the chart through GHCR as an OCI package; see th ## Features - **HTTP REST API** +- **Graphical User Interface** - **Prometheus Compatibility** - **Prometheus Remote Write**: Prometheus can push data to SensApp. - **Prometheus Remote Read**: Prometheus can also read data from SensApp. @@ -102,7 +104,7 @@ SensApp's architecture is relatively simple as the complex problems are delegate Most of the complexity lies in the [database schema design](docs/DATAMODEL.md). After that, it's mostly some code glue. -- On the **edge**, SensApp can be deployed as a single lightweight instance with an embedded SQLite database. +- On the **edge**, SensApp can be deployed as a single instance with a SQLite database. - For **medium** deployments, SensApp can be deployed with a PostgreSQL database. - For **larger** deployments, many SensApp instances can be deployed behind a load balancer, connected to a ClickHouse database cluster. A message queue and some middleware can be considered to ingest data at scale. @@ -112,11 +114,13 @@ Check the [ARCHITECTURE.md](docs/ARCHITECTURE.md) file for more details. ## Authentication -SensApp supports **optional JWT authentication**. By default, all endpoints are open. Visit [docs/JWT_AUTH.md](./docs/JWT_AUTH.md) for the authentication documentation. +SensApp uses JSON Web Tokens (JWT) for its authentication. Visit [docs/JWT_AUTH.md](./docs/JWT_AUTH.md) for the detailed authentication documentation. + +While you can start sensapp with authentication disabled, it is recommended to use the authentication feature, in command line or through the web interface. By default, SensApp will print an admin token and a link that you can use to generate more tokens. ## Built With Rust™️ -SensApp is developed using Rust, a language known for its performance, memory safety, and annoying borrow checker. SensApp used to be written in Scala, but the new author prefers Rust. +SensApp is developed using Rust, a language known for its performance, memory safety, and annoying borrow checker. SensApp used to be written in Scala, but the new authors prefers Rust. Another reason is from the results from the paper [Energy efficiency across programming languages: how do energy, time, and memory relate?](https://dl.acm.org/doi/10.1145/3136014.3136031), which shows Rust as one of the most energy-efficient programming languages while having memory safety. @@ -136,7 +140,7 @@ The SensApp software is provided "as is," with no warranties, and the creators o ## You May Not Want to Use It in Production (Yet) -SensApp is currently under development. It is not ready for production. +SensApp is currently under development. It is getting ready for production. ## Acknowledgements diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..af936437917a2ea85d566c8ccdf609dcdda6185d GIT binary patch literal 361031 zcmdpc^s;qLcbqpbv{i}jQ{TtI!68ytQ_{o1Aq3&z;BOJ!{=Fp< zrwk7V=Qhp@O#@|B56$0O{y$!B-MYod!hZYqZ6PsfIu>pgK8YvPj66aT6k^Px;=0O0 z;ykK43jExBV)C>y5&&^#1zs6JX=Pq8=tFocWl<_X(T>*!%%+khA)`xOtE&+M< z7v2DkSXc(J4TZRci!3;(r1JAU_lJBUQnnH*_DWpR&x9&#>Pys|<&>TaiOWsQE+oiX zNGj>H>2o+(yoH9x7L--bEw1AQ;uGtTTNp+g!<#&`m||45->7hZ4ZKsN#T6d#y}f&2 z#M*|PTZl(TEYPFvML{K(f->(@Sv?V%_eB+q{>INm6k5%JW^Ds>QC2OQzR%P0tcGR` zH6fl370-eqN<0Wzz}jr4A~Y$EMqyFR6xWJSg$Lyal1Sgf<2 zact;9mIH}ZX73ybG&nxPXsJ%4qZnuTL|EN`nt}ZhB}bHz>5xvak)p*rQ((2Zg`$!( zmjus7U?5Er&AhWT(l${?h)c9AI461A=g0bJG8L1cI6JqTn4qMn2oL{LwKw!38o$-A zTx4Ke>0G@&Pp=5i$KkVNzyBX*K~a4hkL#IzD+?Q9~1etd^Jt zR9Q{YN5@uQ*Tun-H9j)T-#tjx92WCD_Q-SZG_`(kh4bCE9S#m7j=GY(f&UyjciXGH z@1csW_|NB40XG}6QPY()=&m<)9NHP&>-Fd|WDG-ogy~!M2c3d{uTPQUHma0S*1y-; zYTbaaF6`+5^*=jnMqQkhp8nZKIeo9|O&8DKzoSB`mwTYa9Q#CnpC|swqHEe6=CtZ< z{?kK(_F;7AQvS+>YAEbVP>7G=Y{Vgy`1hv2_Js1^7*ikY-3Oh|<)%)el=TEzd)C-9 zAM>m7|8wGg^qJztb`(^a+wMHVtN3+KrVZ%Ub~&TpluT=da8EKKR;BZhp#= zsf{?eseF%~OV;c`>EN)xqec%>V5aboQK}N%ETgFt;{ois zQG427BUReh`q;k4$Woj${{ihGamjT3>bmK};@yxLj<4>(Z}jm`S?AS45mxT(LmbQI zpyxA?MV)J@0$GCg|8VFu{%ZJD52ZQJ7VMI%s$T19GDsfMl2H|t_AKu=4&dvbAE)bS z|MuVUccA*AL>EJ!G>R)0*&EAlE|%T1dz|+HZRQ8{O&N}KqyjV_9TyfgKzWB#5Tm)V zdY(5}ThBXo8#e#3Yq{bTr_+-&#N%+WLl@##OiX;?xdjMqQIP)P)!(4x^~v=1yQ*hh zowY9ErHoVhDYAwV2s+k3 zlzx*p$R#i^c<&S+=U>7#p~m_B(T!!tx(o{wnW1$aW=(QIdC7@QNj^tjUHZkH!!xd) zM09d(yKwhrITvVy zHoFkXC%{SFSpT*o*QB2kr*N#dP^ay5sz9MJ=C_@1ePJns&rYZ?5bePB+Yt49x!z@Wj{2K;!%}C=F&l``Wvavr(?OkpFeDK+BPuflJ%s%wGE2 zWA)4xx0~4?O+{*n@~jK^{_rMczWy#*8xq4TX=(Ry3TG|iaJnh9+s2cB#bW@i8ilv= zsD>D)oa`Piy=OSp;*B^S`?;_t26Cd3F*UMr6@7n^e$S<^&{nB#K7m@gJ65iOn%N<6cX4_Go{S$-Y%v}sq0>Z0L)y9uEPY5Pd&YEovv>XHC)9Sy5Txj8!~S1oIQV-)CoS{HC_ZQm&6MzPj_d#|5(rt&gkR4<=}n5WPk%1P&X{Rz zCSmrHdS`^G+Aw*ioW9%a31nrH&yp{YW1w+l`lzvO$CM5(`N_xAkaTvqJa&#H=}Mx! z`{>p`*}c);RWN_N#6F;UW#pX6xp!=ti*?>DWRP{uF~$~+u;^Ef(ic~>y_(M8N{HRF z?=MkLNa0rcq!asV0Pvim&c9am$ShEnL_YPrZfmhIv=i$smGdv`n~3~hb;Z@Gtw&ya za!0aTihJY|$9O|6*Ud4kZ}hhJPf6!53v+UJ+13{bq=#MNq0@!~rKK7PW|E^jL-X%d z^Kz$YG1n$PLQuNWW+!z`=A#+ivd#JR;U_yRmj4!~&RV$KHpXmjM!VK{Fs%H6`^7$Y zd#a$o0?+s3wFP6uFwe2Ds~IWMHxrsLwk65>-HT!dx$I33z1cNm=bu(ddo3!rSuJ_i2t}0dRVYaq5|o5blCZo@Tvd-$)+7Ao z*njZ+w=*y9>9LPm1-Zswp<1Hs+h~Bd)B;_Soc;7D3&(T_)5v8-ntOHQ?!M4K2nsA= zq|IF|j5Y}W*Nv^O(lI&rAX9$c<9<~)%{`#JXj~gJVv}ZLtDzX)v0=B=;yL*sbM-Tk zsrpq>$S42WyhyHhk1~|+uF+R!AAf25uoTFvZ;4Xo3LCd_1ehB8p_+DEb7#64UL_z< zr=2_Jvwl00Z{q%K9S-}yn~+jlQF$4C_r!8Bu?xRYS)*OQ%to2GTt|(@nTk$Z|1`;Z zdL*Dk5<;J|fCo9g9$}%yt_LsNtzEZCGR2PPlARYyI07?vry?xA?@${%;D;b#NO(ed>iK8lwE~jHqgzF+*a3XCx$PoBKZu{zpW?^$c8R_^`*&J zshc?ZO7-Svd)=uRPM_4_`TW?^PbM!$4}?1`lvbNRD;o?eW=_clA?q`*q%-4WZ=t%2 zNY-+`tQdxZE^+@YRc^qTr3U$O)nbaHWeVA1(IWWQ;M;GlqWN3)i((XeCDMs9j zCKO}55z*7?Q_aRppu`0o4nwvC0#$M*N@4b?lYSpb?gCUzvpU@Kh+qBo;rC??#!g-m$iCni|DjYbFFC zd{i+WTn-SB^I%`c^2#x&xlh)1vc&>j;OB0-J7H zTpfCd{zU;8_UoSdRsB>O*w#XH;p&Pj`wJm|^K>Woeq`p4BG8u)sTvP_^Yz=38~b(h zwe`*IdvUt+MeTsaG72H^H|b8nfz%tDT0|~|)gdj8X~wEsP0B(>9V-IN7X}!!(v9^t z!>i`p`cY^F#loehBZoUQ0E=BTJDr;SM`@jlOB)xTCM*$ZFx#tK4TQxa+f{>Uy81+P zSu1NG&Rj(rrH_7PI)uk^+Pr?0=~O6bZe$d{axgS+&`mf<$ChGmA83yh!fW=)WdRm&^YJqR3dXxHI z3062by!~Om3Vh(^-9)Li!lOE|=~y3ZENd~h+}zfUZDuw9>ao-jNl3wex%U(H%Y^2c z`dX9KjCM-{q4=>hEnn$|+=e!23D^IlCN^HT`bd|!l9F{I%Y?K!C!{>Iu$j@_&+oh2SHHfYv10{IjGJE9ZOp{XVGLq)&=UNb*3D7 z5PbfXC3Ek3^I)v#TVTF3o_yd|6QQ8_2d$Pq>&(XM;En{op{-|3gA34_hXN!&B+2rY zk}pyZg!o&EcktyXHt*A^nL#W z{ET6FJ}>Nb&Fc?rp@=0v*P%|u+Y3c$Od*=_juaL?4~!%|NraVIN;;fYUn;*Rq=X0! zTszExDNiFc-=&^N5h*)ndJR$Lxh@HbP!meGvPaCce?rwK9qo5U*Jl~l@my1WN8AGP zshvR9OgGOSoz(!>J1v*N+(LgR<}Xh6mQ^Zt&kY54R~;1LJ0CzU;+I64?tpE6)+M=! zB6~zX4jI34kM=4T{SUG(UaWuofyWa!mRj5Zk+!Nl7k<)(*{3osu(ej(kRjGy+p|yK z4d00c|H^e_KK;J<^}5sDyYw)oxaQup0@N-P3vcwVKm8UV-h~Hx>oKi!B208gj(~#y zpWNKwEZkl^sAkx&3u{}`oa{e)bXolpE;6OA_7zdT&)L}jUS`a*&|~c6+rj<_`*78# zFi^D1N!zUd0~mT(Z}kOQTIJ(I%-RpXW~A4HdqlUxmWx^UOwQyLW-x1)$cKYw<*hJO z8F=4--lVOhzv_Ij%dgvDS^u@WYPe-bDEt2uB6~^FM(r<^epw+9&`9^=5TTbK=<&4$ z)llfEPH{3%#b)A|s-sK~;#PqS!k$i8=#g9zH_)q}Y{&Rl5Pl#ar6b9J_SD-(eH4Fy z@7%!?@sRPu$C3<=?5FnAw`ra#vy7S42P|W)sC%|2B)X+L1ZKZ@Qm^=S5kGv)fB%1M zur&X))SrCS`+38?Gx7b3?iRVAH(k?9`XE65MFuesVdF^?M1X= zdU^Dyv28RZ&4I`n;V+b**JA7A6S~pHt(X4CPe{?T_VIMxRV9IMZ<w|GF69sZ}0w)n-P@}HJLQ2Xjtcc>;*^+c9P2){QA z!QeG9ex>16kr3G@NG~Za3itj*)Fz#SH^VhDlI6<|=vlVK1UC}sb7pHcc7IdkT)qb* z$d`1mkpe53YSs>i7uD#kUIxYxy8 zc0jhG6G#7;$)79%Ucsbg&NExmE{9-#mkIA4lF4B1u||Y&Hw(dt!rh+3B)^Y%)*~*E zv#0)Y#m=i_D41$uLT7eUAiS3VA4wMblCG1H#t%=ecGbMKUr5ZxUP=eh8w~JMYs|Rc z(!9MDom{X*m%mOoAv)qo^E4>)XbNhNA%xD;m$w1-y?I`r-ycG8?N2Q*{|{5>6F=xv z(!~Q2b85m4@~;O6S@~hFQoY@Sb5h-A+z~VPBD!ntfhE7YCENAMs)6DrCinb@z|=hx z1|Gu;XFDo^+iIb~VuMxn?|N@j2+P;F#3(M<3KdU7R)i1h z?m%UhwAvX1X~+5-Hw(n)Evj}b#x5~|pO=AF9Ci8CGI#Sh{s#>ZF&gyaJd>fSeN^HM zFP|lwr)T9zX1D*RO1*polRts?17}t6tHE#7E65G91S_5IQAmE;59dBF&!_P5Dj7bN z9Dr3u4OWq&vP)s>5>I27OCeFCFQNxdnm z$?@9lXk?XX{H2j;KUa_E?!(L^5*bjIu7OilRc9Wz(I+l-`dzz!8F%~Kxhos*G`scYTMcGmJjMik3% zqihg2YjEAD52dFnpy}K?3utJFLE})z!(}6)#?z%Rv#Ki6ZqG^Tv|bkwJiz{(a9Fuy zrs5nnmq6t8EQg2t<{cGp%%vb(3+$$AG`??EUZJFadNrOQ+N+j!JVG#gM6Hxt_;?=w z$CDJ#QvBQ7rsIx8N`CrX4wo#o@4fR7c$5bgU}0;ob(GvG6~k|k{H`*<1n-t5fO|Tf zB-=wV$vt#Q&t|Bed;3#Q6_?$s#DbAyV#3>$)(pwJn;$9EYC9{dQ62WN5n2S!!LNNZ zshXX9#AIV`Q2ZZG`^-MZVLF0(*FWxn&pHyz4Y<>v7wMHmHRTBkEn5 zNGQ7>eP`on$szRSyV#3f!EZvO&>pULWL&=a8w!Ma!Ml(0wOM^F_#5yWFefMBgV-j- z|LNd<`$13)32q`uz(~8~f(0i5vE)7A?cTJ+taAcV7QW}r-4ZnXv_!b(;G0^4; zT$0}-4IPz*S4l=fSZ77i8qi^hUeN5m?T2kB)(b@-f$3pKcx>~5T1K>;@6=V=uCJg3bs%bId`?0Z6&1R{YzZSgg) zV?7M^O9Z$0>cQI(0ul)|xogs_*eq>4p{)(}$=Mi9KZKmPh$u`>ihf)&0=}JI;IhBX zFwg%%-?0w3pSZB^_$RRj+*3EtTjq1aUD{st$-ax*!=Q>;O)?(A7zujm37i->X$~aa zHR^s#sxH@FF>N@@mt`)=#Xvd(7;-UZn}Ikzm$I*r%_DS2drw5T-%p*8?0_&Na|NFp zExHwMUAP4TA8ltkw!?BTU#|=F%KpgdA`=q?qSfE;)pzBXlbbksBbIwDBMP4lLt`oJ zRG$$6rG!DU=9lIIUW{#3a>=$iYOI&oN6&8ie#QU(jLXKEWOPc1?}R+htyVgBaI~z` z{(e(+^qgy-TfV0NZh%@ri*8``r@w`JmH+raJY_c;l*7)tEnL#M*dvzxZMO#kti?q# z-+@btT#n@h2Wz&dM6RkHU7zJ3VNXHN%jKLe_jV zsLN+~V=|tW<~KeKSH%vC-$GRV zNQDYoCGVXCihP3xXc+b(j^FZOP4X>Q&U~eVF?%!HnC-@V66hC~|JnWxUg)r{pVYQ}cM=0DgJb3)bbQ#3YJuj94#h$PiyPdAg*s;!?|P}Zv)IJmqieO_ z=I|HQn23uDOjNX_4))^W?d491?Lkmeu1kveK(cRf^a)s-CY-nqt5)K4IVf8<&xa5_ zD))W|G-S%YrqzOUUuJ(|oISyQ=#2tE6fKjdZF!xzg#GJRC?5m?CyE5_&Qe^5w{(^w z@qN&z5=WT=-Aj+RmP?&%_8xDFX; zZ>V;HI%r@1WcB(dOXbD1%_zxjbnnsjJU$7r1SXU9`+~d6`R={Zzx~nRQ_0Ns&Ou)O z9i343hpjZei~FvpeVq1lIcD=s-{nvlx44E+DEr(fK0`l>O7Fm7)`nV*eI+LGT{mq* z0TqfYi4Um7pGa*0xi6wp!OITW~ntx<1y&{XPv(n1&9 z*?#H{^$cptU_v>)3+_y{?6CXiM)52ZDV;wrRwWYa-N(c=&dl|km6GX|3E_vaIidR! zZY~rMGrU`KR#EIp6iIhF17C49JxZwPAXA~U6XP-`x4<(cvoppld{^05B;)*i81A!s zy0M^PBE)+wZ@zpr{f$X zg%|^GlCaV}+rll3L8tHyt%JmL_&&jM8|q-~%gfAOYQL(#zdU+(8~FjKm+6Iur`hUD zLlRuDN>(V&dJUWET=kfK&YneZPiivCN4Y zvCupP*^2{RA<7zchIZTBTCeBeilN!-=%&D$@Acp^a!*{dOb)_kWfM?#r4#ru=LYAA zl%MV3*A=6nx$b&pX8n648|Fuuj#&J&X9o+pFiW#zmj469TPAO2M8VK1x98jRs+6 zRW37Q=jJ(RU}EvZEwipoev>NToGx#b#}36r4oA7 z-cbJudkL^Ev@R z8u*Ae_((Tnvc-l(HuwQhw!ZC|B7eOx_xQR3iGJxaowU6`d$gbM>)aOpQ;x^uJ@jWM zxBdj$P;?f;2J|%vrKV)BkNDX6Y$#IDWII!T_~NUJbbDKD&ZVB3Ph=lG-G3sCbO=Kp z_13b5ELYLn{eD-+e5dNky~f6B18#%`Nv!Qr>x#XibL~?uy(h^bJ9#9t(LMfoXlQMN zpNEWCyzt#{G{c{eI162l-DCb~_j*@WR>E6a1Ry%`tJ=a&$Tq(OZoG%QwSUApx!alS*Rv@TWW-D)sX( z+%?K#Se0K|JA`6jdzb`hX^Rk%>A;h)IgWa5cWIGj>?bZ>SI_Xtvy>-}yVmBjkd zzGiD2y)eBjExIW^f#2a?-z}xcq_`)_vs?kr3s?<9hmFXj^zo13CiFK>I)iP+C4axa zfzQsdAC^OD8H!V73BZ2>)KGqC@k?X({LxnFk~A>}&OmAf;czjdl=6Jji`7)%^Uz66 zG1qS18jCJ|^>v9;*E!9@fCdG{{Mfd`ml;MRZ){z}>l3Aoq{lhDp2ili5|R~M|2qpX zFRQ}&d;aG4!W%ypULZOgv2OT_y8pN&exl+FYC;&5h1|B2)+m^ODZ7;xSD_W!^W>hl zsqu^NF(+iCZGO{&eqzdDg$kuvklc&=p44RWo`eQvQ1rr`K@v$<{TQo4+xu%3XYn7| z*V59mWlvtNj;H1DQ6#ZvX(cDWr77mh{L)_ya%b4V6yViijy;)DMXSiZmy>g;eIXF| z9zjjcC7nEsnH2@tnks*7aQ{S(z)nEZSg{1Le}KP=6j{(o<-GpP^97mvsU>tM%?Z&i zy*+F?A$mT>ul2@_fi2Uu66}3h>IdVk^Y#{v$8%qhS@(kIXXY|JITFV~#EX9d;8;qE&+Qy_hYi5vZMoH!G{F zl$IfXE?z^Ms54%AQ}zT|FN)C0MK){d7xbWG-vOTUBFF;iSXAs# zc8d$uzIlQhjybK9(cWB{V847yr08Ma+U$fFq`0>Fw7@5mUsA6Wh{lE@pL)1J zO6HPX--@|ch&P*1TfE#@qp&a*h)v(Y*dcr|jV)-j-$rFJ@cWau3XTcrn$oEdFvz=B zExe9-{ikR7#KH3HdUXI>V#;I3e*~$SF020W?0K%}30vln7#quEHs3bPf@CzXrL7LM=?mtb+km!Fw5UOhy|(B-eefmv)6SG6`MmkE60;k zf$ObcZj7LX7;$Icn(cx&TsBvBd%ocK^w*;VblK6zCN6R&)!fI(RadtXkl^7zHunvV)LFhZJb|~QBq!E}iEG)gPE>xbX&o`Drv+pv%w)K3Of}w8 zIzH%r3i*khJ;rZ#vW?_dnPbCl)8%J7P#qr%_H+SikgcVj@R7h3Xe@yDcIctGc;Ao{32LF2Svtv$+KbWl z$t%X4nJQ?&*@`9p3*?_a!4q2x7pNP6;3tg>DU^$*QF?OcB!yrE)3m5F2QuupvrDGD zoo&1wyZXV>Wh0~rgTew;6pT*}f@oo*fYGVP)+Eet&nHe4oIabB^Op*4k)}-O3GY<3 zRJ8kRC=sWEvp}76QOu}%!wzLG7J!s_K9RPfI$qJxO}eTT&%juTpyXIYq-19Wq5R zeLEqd3KMCEa;^SpGWC zS$56N_{xy_*I)*H+x^osC$su4<(CflAX%4TjEt1D!zq0~N9Hpz8szc4q;%KE$Q+`> z+6FLvj-A*>ZBR?D z*)l6@(Z0{)9XU}&@S-lA!->~Bjd=lYeot?)pe~)HN-Y?b1>~Q&^|8OLdfAnBYM1|+ z6YTb+jD{ho1d}6ieh}@L;?TLIZ_Ir-EdJ>4Y?lu}NpGsAgt>DF>!yaLUy7~4wP5)N zaGrF`eIh<>al7bd>?Rem-G^8DtJNFJZ>nhl;?iBid#7=pHc$o#fr zGm03{UMuv=gJ06r!P!pe8QaqXOL{uf`gW%gzKOOD zq(NA(8|cM}u{_0$YWQ*YU;)E;|8-8BGddb3oP1@;QI2o9bV%}7%B+0VcTWY}-5sQY z92D)H{JCm7+tHe~s1V<>1D-~ts@V!hswn7LIhE}8{z|r6aqAY4`IZo5G4GvuM_qW?t7Blg8p79=anG^7@V?YBzb&&)jZBG zPgb>uWe9ROP3>ul^t|`$_VOI6$1}9?MrO0faF706452TEf;hsb0TqLn*M7(?>Fyb)9+w>NWSw z%uLLHoe|)nC+$V5F;A>x=ifk{@n8nKz zJk#Q_G$O4{iRf$ck&}c*->prG1N9+tBNBQ#0wB!$5g%Npt@&$nSbXIJxaaRD9cHuO zM7sM|+vOp!Mm?iPdm+pFwx?GV_O;2~rLHZyR2lHW?04-<*o7QpGt==%v~-r!1ZU1a zP$I}?Y;PV!KB}8%ZOasJQ0gKmF#Zu@ZvIZSmw;>>PubuEZ^d$~l72s+1*Rxkj-4Bx zg*HU>z@Q{WG4|tiajzJDPZa_lz}tC+i;T(!CB+y(s-Yp+Cw+-~(jEN3MGbSv(r$0O ztHTf9zTh=TOe_QfpKP_p0hJi`i4k!Y*M7DQr=C{-D_6|PD#Y-`>)50#S_hgUxBhw ziBZW9D-zvf0%=&^>{vsssx6#q!)T1(#!0$TuhzS?BBvWg#|C88A9^U`SO58}h-r07 z_HZS^o?hS_e=+*=XDkN_=N#pJ3{?1AM52Y)1-%PV1+w-?W*a4bz@cvaY{-Kq6Xa|s zLDg@RCKsH6G||-t6o>4{8VqGpJF`qHah-CroNpsWVmtkNGqk00|##(54vNaIiLV6lhr=KWOBNgahdU89o4kS#TyxCG`V+cBJAcTnGq?AMLyiZP?Rw zlhqdN$$QuHMRijjUs0^<)LN+?tWO~e8&w#k0{xzicvn%FXaC6EoU} z)8$XoAf|yP>{3)-E7!1m-v?GC4}FwL9P7wpdT9f=s$48$U70}Qs+SnQ~yRm zo?xW@1Guq}6`nbAJWcjjBHS~HsK7yJ;6$VAglMJT{ss{QgU8y_QZQJjC4_^m`^lrw zQ-(fqpE=_Cid5x#6P4<1BUPCY%sn$18D8(+6)pG@d7~lS?s5IE7fj2WdnvK;(p3Qz zT9_8E<=`W-B*d<0wdhUvr~T_$(Y!*ZHbnM#CH(3ILqzQtR$FlVh6oj? zVPFebbJ}N(=&o|0=K2qpZWvkt9K^!>faMck&@HB*5S1vqPF5Nr8Za_xMLaQGG>;9{ zi5B`bME^0yUXxtTh9aTVKvhF5KQAsG9pD4%oGXawgHU0=(D0Hb97>+_^c19m8C@Pz zA-;Pl@epxeTjk74TrGWb`|+S-w#k-qd5B`T`0b>F)9J$~kR_$$HCa62{{aKel|Q*_ z1rd!c0CN^4-($&IiQd`b`Iu}!vQt2k1A82UTv#r!MB34I2rO^=#QEQXrat}XJy{<^yI;NID^Urb$RZsV88!jc30vEF?Y$?3Kv>?KUzSRiPI6P%2g9Eq*RA7e3 zj@*etdyuWqK7cPisc2;lUe>B!dD?qkp_x_(DKf6I_@Gx?jZY=h5MQh|xeg^MC=4<| zMcV5rh!IniQT~U9slF(M`XJ&KCg)GvKUd5w5IX`B1$&0O?F458m!@I{<(72XC5fR{}CrdHIlvw2( z_21P`^|C~{?cAxXsXFNRUG*%|f>^4K=VV5)k_sT6kfAYk9x>K@!ibeO8bebO-9@Sn zKZeI8{cQ>fq#%j~S9iBqlX_~QWpV&Bs9GU@Q8=w!F*cuo^7VtZdrIh6G=j?vRc65A zIL2J8U8Pif?d_Fsv(Jzd%x&&er}V}%lOPfkMV1`;PMf5wEv+_nG22BQwhe=Q*JdL= zE-g{J32oDs@8ZLZW`ea-mUi2dg4}2dc4(1e=L_`0lD{dV2rIxtULeG) zEn4QRaE$lPDdt*<%Fv^&;VX&xZqdt*F7 z?<_OylihmmR;$I3h%uj(4~y?zWD6ypKE`tW+Wg9Acv>xUJ#u2={#o-bviG*&!7abI znBJGB#ZIi<;j@iI*`Hn0ch6e-AIOXS)#=n&^>n6)A9|MxH~_{b_&xf1OvGm4EuX>f z>}-urCb@Uz_ytA^Y+3sVox*70z4Ozm#~|$GHB+Z@Aq|RSeOAuoR3hb<|jcl!P%){VTrXlGCYpuux}aC`qk=gu)nH2Owv381U8zr%m?Ltub= z!qVq=9UR}Bp$angLDw>)W}&C{M|V2Gh3H38D(}y9U5zb!=T+tUqh66DFZZe<-D2}C z$;Ub6k>PHDiylx@Odq+m0}Vx%ER#OGM956Z6y80((iFdEhSQ^>C>9Qu>$Q)+T2@|E zhkQy&f8}iAmzWwC)UwfUGrrYdWrDy>1#1i*(f7))(z@93*&1wz#ptU{uPwt9GjFte zS_AcxfTdw40`4&B-9~ALIdAv5jaYO1OTHgyBrx9-fF&h1&&!|Lx1?Qudiwq=v@+{3 ziQf;puqlIbt9q-J_9>cbZMG#CY^omK-_(uR5A8OL)%wb6`CcJXR^kcg6hM`v?vcdwU;2!BnX`c5!%uK&AE5 z*Fd~_`tZx~LE$!-G`z$<&5gp7vawH_wl)><23!Nqjepmasylb8~SJv!p>nS$hc<`DisbwXE#h`jh+HL=5a&t%( zbQC>M@bJgw`1a6~(>gjCk8g4^s*g^zmw=0&=QU|+!Onmg{JpK{g;(R!o92!K;i%AH zBa5XgzqU@ND7Rd|K(=20*T73WVs1-0G zzqhr86OS9vevzg|nZXwigZz~qNa3d$U)R4o4aksg0Cnm<+s1Qsw ztH5yZdFnSg)-Od#z+}$AJ0KSkuPf3n?fl=BjWl;jHs_< zwKc6VLr>U1qqfH~`Rf9a1SYfjYEsb5 zlljNLc~SoIQn?(G{_%(IxrE3bSX%W!bFA+Rp*?mVMJVK}wuvDexQqt}~&wzI?%M*oE>#rY;@I z3pF5;td96BLiF^ZnEHOffOrA87zB9Fgp5JL7;16a9CVQJTLNEMu+j|hkOl&A&}|R{ z-PNbPHKD&aR_LW5W8uU9*RNTRX2Xffw{Zq$L5e zZg(m&E+6lfdLJFXgs8u_KzrT8Kyh=gVFV$i_I0KrV^B)eC$E!R)QK*Ms{D65PF&6} z`P7!{1p9h=ay7l^jst)MNv;mU*%=%Q>UlDqmBrY0@+#r%gL^U!jV^s+x^ixlgOaR$ zKf8UOsot3~j%wHBH*Pk^ImBV`ZZv0q%AwvVW`$`Uz01z^l7N33_vKq>|HZSASR%p4 zZ}gsnf*cK)$9iP>3HQx=oCW6Zt$A_r+u{zu{TTcF zdK@zwV9p35^lDtYunBL6-VJ}$L(U}q4$i^Ty|T-IgWQ^!e;Fbn&qv^CAlBJ`*L(GC zsOB0h0DGx7gR#5?w`>dOoPd|{U@3g;!ufKt;ZCs(BNuAFpR~B3WN>8H){vmr&(Vt3 z*RBz-&t(DZSb8l!>oMk-S>A7ZlG>#sKbU1qBStWkN}#rYBXTP2RdjmN&W}KgnR_qo z)7&H9ol_=MCf*T|3+oSL>ATx~KH1VJHNPYIP|<}W^@>kx9{UP>SviXboIPq|c)QP~ zl?=G!>7-D@vFv(y2Xi3u3`_u`iDS5-zD#_mqOiZy+k&&X!Gxuj^CqJwthcg0?^8OY zmoau+ZVV)r&&fwq!h>Fy&XvbzJgn4o!+6QbJ{P1E=zAvDcIai%p@N$mIo8ZAy-2F0 zJ07uh;?5%Y&HN*?9BxB^$Sbgqc0thqm zyMGPV;UMv(PQ+`pW&q8qTr{7-0AAE-+~xTj0XxOkKJRy!WOlpI?HoA6cqtmo%btT! zZbfSL%MlbC;&+_PGs9*zMS0tZqhv{EZ}Pa2WbiUGrRU@U;YlGrKF3GgePW*zH*#;- z&kAX#CAwD3?=m4K#uS-?-aZHipBam39L%={s|4Te%x54Vwva>Epj|MVA^e@qibZ zwy*Q5J^s1(L!$*U<2!E2V!*|e491At^bGD-Vx;CAy|^eW7)T`6f(I|keW3^m!z{2T zwKQ8^WiW$azX!H>ao_wUTOi}yXRk<_z?Wram9DD2h(S>l9~FX+cB+Gu=3&xqp<_F8 zaVR$oUD&w#^rvgC{5D5wl?InJJ$zohWPPXOwDAEvRL&+XK~HR`>S5MSwB1B4OaS@4 zmtr)3tZd4pHD+_5827XLfW164XXsQdT9&@Xhh&{xexL^jPy>40BM0iF_f){_K}kj6 z^1aYI1@t!u8TXOpOlCAeC+U5&+O1E#ptRAlkP06GGWbym(un|sy$3~rz{3NkH3Q4a zAv)?%l)TtiN$rTe`iA)1IWNV)AUz}hQ0g3i#u(pU1H^ZXRHy`k5{CUsR;V!bG+ zVC=mz%%^1(o%A$Gc38Wh(POpZGBJs7d-jFvQ@e0}vGQJ(mWU08rz>po{%^!-Z^3h; zuCelv7@=2rni3fKK+3r8yS@^&tpv!<+1dpFg?7z0k!o-{ZX}B9w2czJxb$5$4Q}}% zji?IEAjpH|V6wvNk&|LSZQlLJ!5u~zXdgg9~S0nDYuMi6UUODM-rg_2 z<-Ap1?$&^6WeatqR(WggBYm7|@%1%!dX#M7;Fhv4%}B;V4T}zaB6A)21!KQqQbOuU zks5`_%MRt&F!Ng&mOJ=NeyX?8#+$QCrjVYQPo_iR)~^p;7GJCiE=2`(Fw(dqH-Zk5 zco?O-T7_BBn)Ey8j0wii{VxJpty{-WAUoa;`-kirPrN$sSuNM=vvh8QPm}wzZd& z$=L`+ijXP;?1~gr{p%?h5ubfH z(gvT1vfH`iAj>b(>7>&G1b(~tl<{q3h1?innmQk3aZb@a5NF~ir^vL&mxzSdn=VKz>C%e?!4XS(P528Es);(D2P!nDITiN7= z=tk^&^cN-?ZNUxm>r!H*Yy=@22+Ms3_3tbI3(2>8FQ!q)KP^|Fu7`bWi=d@JMjEah zd9e|!S|{A;Ti@e{NQq+-AW|pa^Y_>}08s$jCC*Q1)#Imn-tW)%_qg*X_nvcJ&)4(yI_ErJFZ2<^8nzb4q}U74jd~W|o%1lT z^U~V=^S}`)0pNbmXD_#cD3UHWWWp?@ZF(JKYsAui2gpC`40Mmz9hO-le?JAey15&6 zx|ITf4L#>XHkCOT!?1F0R|Oe+l4z0w0LzS$o87133Qmx*K{XA>6*O>?HN@IM1o%}A zks@a{zr{~Z1^n~u?g!!bT2sfGEy9(bO~T)ag} z{E;;^GR?j&<8O*7Mb0yzZO7MwHd{Kxv4H`5J4bq&x!$Ub6B~K0sd#*E`+1OIJR{?> z)s7)c>zSRH(7^87hFxQI__Re;TvzS3@Tu$fCV6*`!ko6_6(42KOtMosD<^6f`fV*} z=+UFjvgyacrMH1?7A!{cH|}X!$JZ~6#Z~NUk|RR6g2!ja!o1-MrcBTSP51At){SB} zlSFfm!hE|`7#;{E!_G>4JPVE-aj2(x?e43xsegL5CyDyKDxkaC=tsO4`Pe301mdWQ zG{8xfZwqQcw^}1Yk9PC&alNNT-Vmgpx$xF6i$HQP(454eN7FpB12ZSLL{WW@f(Nx- ziHaJZ9K|N{W5U=vznk~;#4pZS%UTvYKbh^yCONTP;lAvs1gr%Y-_ynU&qHa4K zy>roE_}41(G^Z!SP(xeCsPlN*DO+*YA8#tx@tSM99`z&Frn&62Yp8cG==iF9uu``F zYmq{m<{_tK#zN~j(ZYhd!{p-$$6g9<`rndhOH(sH%6KtcR{OZA7IDBVHRcoksw{U$^R^{-!fsxJnev z&F*4Y2R`(o(u}`d@ONP2z+$eBGbEUPU=Dxv>r=33%k>y}+WuBz@s zd_I+;bCafV0vyShZU?*}%pb%|6!T`ZXeQwL& z<#=w6=NO7m0`#wB|Hyc1ejYEKrVCv8#s~n4rYJsu3Fv1T} z5_do)s^Nn5(k59_P7s zZ*&8T>+6}xytc_v--%JPFQ%@<2mkQMRVeA<^juf){uTWP9OnU5ci%oxs%4`AT(kIQ z&M=gG){>*p#clrltI0H;fw2nvnF&pV2l?ySxu2(CkMZStd#2~XWniWA!@?W-#P@hC z6@48cpOKuC`TjEljsLt~m|=hfGqw`NZr>R`46+r}s!zBd?{Z_0HuVo}G%Ww*84~#?SWja!^;;>WhnGVLWXiEw6iWEy5}2 zP3i|--MJd*D@I5<3kbZg^6a92!6_O0`;1^6qU6CX>jT6aj^D8Q~!qV z?6xaI4EDtPjS1!WVCzi4;Mh#-{M=`=-kG?Do`7N_;a(5#t=@c>V5u(opX>Z2PA3~h zfaUxEi&SQ&9dv3mfQQQn`M&Fu;uj7EsI?%U)cw%Qxa90ni04dJmqXdr^%AEQR?sa8 z?h{`_hQ0h{iSU&KjvQWY;Xi$4k$mWqp1nx1!^tp#5v+RIs-Gc}e^k!x&0K?JAK&%! zV!5XA0-4V?tPN!~lrrBaPWs0eFR4iZuJ25cmw*v(tEr4;wB+8`Ci6exuJQ@>;>4U- zdz-WQJzllkM}GXf$r6D(YB*hHNLE4MBspULTU}rkT#0zfFQ&$ej4If!1n)?UQ~TKu%d)ArV{KZs&i zN5b_=P3`-tPMGF?9Y^fSC{wy=AHA{KkA^8KdveCd&> z*BkITw3%7JCW^-C4opn)ow}{Ym%iP9CHi;=I&-(-Ejq*z4%HXNVrPNS?cdw$k;iyz zzJJ}ZH_YUOFcY^~!l_Mqg2rjU#qjQD2?Mj&HmDgC{;R3|J%fuwIQ!{}BajgGsT8r) zDD9bXJt-xeiu>^5T3*bRoJ&u-(S7NS$DlkvCEr~~MMqu*UB!Dj)Mze?!xS9;s-w+u$;!HS zeoocq(Gl$K8fb%iqM_X!w$%2>o9LwH>9kD~i9AtoYfj+=d|J5HR{EWL$5y;zIhz*` zS9Sl~yLj#(tA)*+@7K!QN;uushy;3E%)Uu0c@goR-8sL>C6}>Hm6tqy#7lw<7me@m z;@Wn)l879?;)MJWA_YyoIK&sW|yJUBS$ z$pcI1$s!7;Pa+AEqZ45}Ij}KxXiOg3SYm`JZT>n@bvd#eJ(&HP-6IgcqUQ6P{ZJwn zB8O~+Tp-`)+=q*w4115n@Rc4@rez+16nw5;J;^g9Nz(b8QIFX$_Z^N$udrWlxJkJ* zHA;O^Ov_dL;h1Vkt1^!#l5qw%^l@*z5iPGIrseQ`Wm?s8Wk@&n~NUN zi?BI>(1ZZuK|vYe$~sXOb>Gbvl#j%61_u?SZJunZgPBM*d_+Iz;ZlHHzwmRhArMLy z8Wm*2+juM%EBChkq#=PHri_*~#ouLS`n7zdjJ#mVs6wOp^qKbU`)e8!zA81A^N>k_ z%(EtOS1))yHx-R!x!EWFo-=i&4Tz|m9Q%4EO5T?#Jv#mU)UEx%$5_K#a-tEC6K}#w z7*)|38+#;n=qA~*bl=`q5FJmbhZH_|mpy7SsnQ|SUQr*C zTR#RQRIE#fFunX?2m3m8?{OKf$^v8zWiqcf()z*xb;#L)+e#pUg14 z*r6)X?ns$0G{rG|j?#S1`_<8dpdx-)OM0Q7d_0}nAwFwwm?QsdS+$J80{`xv#@Bwt zfz@d+aY(YRl$n?PP%T;c=J-Sostwq$qov^i&wdwKqdwN0#q)1fWk(WFg z^vqu``W60MdbC7sVgYs0drz(#W{TSc^3(_`YM%S7=`I1_p-=6sT>5WBzimc^F3H@2 zMl&B_{aJeB2G#?k+rHf-mD``5%gHM!sWyg%kK3gWgy!JO{EymDQjEd#WNH__fIsb* zoYsgnk_fOx)U>5M!VLH5ktt*Y9-v5JtkT+=YIao`7`%Eh1;*w$5rP5Q>~ExYhgSCkBon8%|fKrb5325rzp=rb=cmz0q*wO?L+*?jeh@{ zLYe38i!w{isP*FUNr}Tm38~fRT!ZN6H|5C3W$WgZpE*BC=e3mVS37>5Hh7)mF7D-J zzc2Ok9N{Arhwf=+;9%>NCA2I69MJboW)9i43>z@$=iom@*yL@^q>c$Ec=fnbRC%KS zO8X6iD%NpKNFLH2}sr*WB(o(oS^@k zo%xK=v&O^~_K=galpMVhgPgnk*=dj&}Ue`98|}Ie0x6 z&j8%Ed;04$ud}g@yh;tWpv0DJ&QsBlHxGp#B(+xrsl9#0vyqA`fuUKQm_Il+>ur&U$ zujuBLk}P{k1ux;leKIVvHSF`JmcL1a<;HR!hZ4WQ^u-3;uPW=%x9ap1=Fcs}dOb5_ zk^W6FqV{p_y{Tv4F8RXdto+U*YU7LXMei4xr{8#q@HMswT)EwFhn94Wdia7R7h`H( zs#mYm>Fz30u<^kzv}W~d?|8go{0}45-8Ro7fS0Y9^|y21Fzw27{ZR_ceedJm@zhyt zdhh`XNwLLPR?S9BXj1ZUB0)bq8X-`WZ73UyQmNeHrGR~!l8>FGPnS1t0$wMt?N1#M zSYh4n=g~43`oK_#ewi4nEPZK-*aE*JaX4OlcL&7_gqocJ%NN()4kBBQK`kMQx=D*- zFv=J2vg?5F`>#AlN|1Jw?VoHk9>4eum3A9SaqbgRJ}>FR7(JQ;%FN_^D%ZV>wHOHZ zhpKl3brr%o_`OYM2K-g|=bI1Xbk-TbY%<3;vI;XMM2GwiQ~=C_fz)*eUCynp;Lfqn8Xja^44C_aZZ^* zB`=nE75t(KY(Q3XFtup}M_o7oeM2POCS5t)?G&pqH6}Zaa77a48pdgEd>XeLP)414 zc<}JJ)-g%GCvur*K5LO#G?WE%h*n8f;r!PwZ}46lNAQM!XdCg6qn9;)y>;t#tj6_A z!3eXyTM3k>;_a=zSsVU#8xJZ*jzX;{dzWK-;f$}J8$@}TU1KKK;$=JP zf7xBWv@~Vz>)UNN9);ce9-S~Lkm__&1NxS^l?}qAY>x7ztL*km9L~mmS!tZR+KN9) z7F!d~#`YAH3?h$0bW7c5JqCU0KMK7NKg{d4yH|Iqz^*52jaZJFV!RuIvr;~ez5PxqVjaVX! zm9L^)7_fNzF--D3S5L^j@4Pp0rP04u_Z9jzr#mz3mOq6G4JPJ)U75s9T=EfjUA-BD@yvVKdcBGKADu}dYP9$L4{i|x!rT+_(TnfG1qC)*X7JI zb_uk+P~IRJ<=`a*1%8m3_~g*CEyFnUwc>LRqFlVpT0uL<<%@CF7rv2P^)DU34f~Rz z)@F;ac*WT-HOnYuq+OtmeXIEu)*B!6PYWCYx|$MyXpXN`W6c8v z{3MQhEtbd}F3?b>3F@W#CMh(2{}NQj;g&kPsXi+BO?F0y)1pxK1nUz?QNK&pIjWm& zAD8QHNoSe)Y^NMB>v7io2)c7*#e2Z__2N&NB=LChKf0{W%EpEJIidP_=C@LiRhRq% zm^yZ7&*fP|6}|b_4&tOm*D;UA>J_sO#^q}^yo*#b-T+Oxa`W-MO@ z(v@C%S}UEv>TCF1Dj2(~#8|GGFwfAh?HznVaM0se%WpOJ4(?l_Es4v{Zk#EBp{jL- zumOhEMV}k~DGj>aopz4W{{c$V+f^BEJn?SK)$2^Ge*I$6)8NfYO~Z9%SqFz5b?Ckk z%~jGyEfw@gP@G5uh4xrO*iJ)_xF*nt>#Wybx)?_$-r(1fl^uGAX|ko7Qv=m>XbpU& zORM6J_}xbkvfZ6-%@Yp*!bvCs=Yl!*RnZaKb8O~Du=H7Qp!u_{tTHbD(YJo~NJqNj z%&}YPi0{jdqsK=`zR2{1t-Bw}BoE)Nznx<7^U+3y?0cQ2>7+nLV@cWXc?Q}ycXdMQ z17EAF@mT(mXT7d5ow>dE-C-)yV<&5TX78i^PDg#Kz^i!Cd7!lj=POZ7jq_87+qZuqI5ZW65>K^oZkqQmj-^%@`E0y7Df! z=!aar@r1Nh<-hvo5?*Ig8`Futv7F;N2zGj_`iXj5?6jT+I!=xaID4~ zo0AmFQiHu`eYAo>%(r|!*zh0DZ=~;icfJR#Obe&cbYOn-f~vCW&*XNo9J4SH z^-cMK-yXN^%(c##&oC9=>NA#M5(iD2DoIFsIr|@V>rfc#b6kdb3ZiM_B=joYBG^bI zq;#+VOK+O7M(=sxj)N~EC)@J6H13;(YF5lEPaW`lVjesS0d0A)T2t&EeQ@KEiPMqz z&0hG>KT6?L)3B%9r(zi)iu#o;GC7AOi8$-t zP)^w7ZY;NDJ^L)X=z8yi(?YJRrpwhaI zLC_&E8+*EKzqOnHd=Z<&i8lnnf-AfCsmA#R>pbb`!_sYe%Ce`)ynJUi;I9mWaqRYB ze()G_@Qoh55lbYvqXLf9G9K*r`Ng|or;J|y-abSs2-Rb1(g>MPmVDirEOcv6Q^Dp( z4{Ln!1y|#gceP*6WXcX)$keJ_xg!v{EcQeB$Xs=W)^MEcy^Wr$&{M51St_TLj@&AR zs2^JL1LLS!okVWtgX@BTYnkWSTDCH8HoKj)IsLHfUFr>@u(SnN?S=DF0XdaE zipCtrpt!S4L);F0_cm@7s~p8#(~huBddEG~YgqNE!2PoS@=JaGUPl#otBafMPN!vE zm&b~>D$+pUsz;-${2SxXt}X?46ZmALVPy7yg?QDfy=v&VTK0-oLWRC%8@gFJ8T|1azAFIBZE*^tkHy(XMDlhi0&7EoQw^vVyHn6m(acM z_c{5HGI3#MB)JW%!EQfX&p5 zv-j}ScVs-bBp=HR2Y8@6v3OJ{{C4E608@uUxR_Zb^Ap!5){0Jpl^w)=i#>p=4&)wmM%jJ|H%tc?7uDc0MKS z#atM8gmt9ec3Q72833hp^S?qaXy38+$T440^jp%!R+jQ`waX;77W8#DdX6S`3KBHKExa}iuzgp z%W?Gdri53aNhGA+AO<1eAo+@JjI1Q4c*Q;M5IFD7>coZ62t*(l)~&9}>}ssWR`s}7 zl@YP3%O*|9dx3qM82*patv{)R^|~Tz?Gi*s7*(-u&I=!|GS)*U?YQ1+3szDAnA8Zg0D76EfQ zZTqZy$RCV1+K$&Vz>4@B5;5Vs$QNfD4)L+ny|NsdfxWs;x<+YXP~rf-JviU-Yc8L0 zmO?Y2$J|&Sf;EYuR58N+Dg`HVr3?J^;l^_b&;FiuCz5XlCj9Fu7NoA;JH0!Nw?c`X zQ#`nj)N&m{4pS_WHN;#r@6tn>0;y^-z$_E!o@dJ+FE)o+`dJ3BH>0R7hV|@#}DQ zw0Cus?(8$N#nEGdN#`3mU0{!{`&|m@{q>XA_4X&!+k?C@2wlCGNxk}*8tfyvph*>f zg#EYh;q}fVYSx=|Vqp)jD3?0)jmLWOq%R8f*pHQ=twoDe|Ep8k&cy}Lz)2Ngp$V#@ z0h{ibO89j_F5W;#N5`erx2EXwhq}@LB_!_>)LtU6(-?{V%5GkZAeZ=RNTb-R8@Uh) z7KiJ7@!yRdqe6Z*CbWw6nQC!Do1jov(>CYiJ* z6LU|<<*6>GZrbWa#g;e4hFH<)q`4=)){_CM^`D%Hx5>E%P7_fo*-pVCNt)?lFjdK% z>AdwiKf`U_K{u@7JLua&xWV+Jb z{G$FXR$GdpKKYom`6iLB00kV%$;`42zosW%vOBn1tEGWZ+jOyd2VO4u`xFL>Lr~ar zxrqtbHSoh{!d1v~L(>E&nh1(RU8C0&JD5U#Q32P=C@uV8ZTd2iJgk5f1I0QjS{#sp zPu78UtZCcCzzN3OrjW@}V)?ZpKzpo#bwL7?j(O#UYI}hbv z8`fzHZ(k^sejmf<^m)o;Ig5Fb>Pi*vd@u84f`Y5dF4eVG8x!Y*8^dC`@js8( zCt*8JGjCYu%$x!DNOZj`)6w0s?@dOo)vQ!-9 z-e{w|sS!r=wHhl?#RWI2S)t$76CZPw9$2DfgXbrAGwQ4wNB;IdcqoCVjz3lm4&w1A zK71C8mq56ub+jsHWRJDC%E263kH0<(d)1_AHh>NiQ}BNFl@yJ=GyUkDCIU}2GeSb( z#%yWfNc|RY$9{q2v{96P!HXB63yjR&q}dIT?@Eb5Iz2r}a3<)L!RBcwJP+vS$B9 zR7_LvaV0%`REu8z2@RRV4nyfmz%V!@=(T|O71>4|=`FieTNX8(PX*4;tor+yuZR)1 zN;|RVh)7j0p{xDdjW1RAE>Z=>My^&a^={Tyih_MxiGlA$Ap@l6Xz<3BdMhSbk1W~E zU!_Acm%h6GPB)o60XNpoAXom1xeiej+sqAwO_-7ec zQV2iCV7VoVT6m}7#y0E^MAxHd;`Y*4yQ-P9aKsk7kA3sH8Z9uSwvf&%BbMG-@T0}p z<$)r>oidKw6yuGXnMUV@0_}g$2p>xazSfo3dj0CWBK^0n`6hiWly}gNUtx!Xx<8Vo zX3&Dy6Fc;9>hineC(X0x`aWug3nA7Y-hm+$oMB}s(nN5%PC+0PAsfTF2Tz-Srua^xQaBfiT7Ngm zEHk#(EL(f!sVqM`vED>WqKwH5SAZU7^+^LuK}IfH9+_FLZsOUjCSV`Qsx ztlmLmrHS>8z%sC6lH@WVi@+*!N zMH230Wb>Yof9ua09b%@?3E&euqOn&%|K&U5NZN|W_ayvnU6LF$F#$z0A_LWt zP&pw$7(CDu(&+iV#FnA-lF!2(jT@aiL>$`s_HQqiP-8$TEkoSO%p|3w^`xQX*)RAg zvYz%G*;X`_GqyJ9w8q6)he-KZ*jTBQEo)tnGl=PgFn3u zEx2@a39ic@NXkd<9Hg@oxi_6|{!1kfhoiBElH)e0wAWOBC5bD}<4ASD(1IUc>A|Y) z;MLuDzmrb$Z1XRL6wckk|L#P1fX?`cF61haXk;*f{OrkkJ&0c@uwhs+&f`Or8pun3 z{u--96;S$eM|fb89&D#?)0)(9A%o0C3)6gfEZjth$gT4o%HGfqt#1K8)DzhFkQu6P z!Ho7}vMIo?jGSnExQIBCDWvmJa!u>Ge6%KD$e*5py;=zO26g$<)o@E1FsP$V-I5r5 zoXGUYJl}PQd&2pzsHv$t8_g8vZv5@Y5otWu2Xu&Si2&^%$#7oPXiOwKKSaZ>8Zjo5H59*DC*;Re&Uz zYdu*f(#CurHJq04k--fWLl%tf;10R*_N%C6Q8Axjf;gu_P;)_bc0QpHq^8Kr5L*a8?N*~GlZk=v?puV-T{a};93dKFmS4i3E_oU5|=j?MFRywhr zWWeZpX9$J|tQ4E02wHGg`?SFdP< z0+dKk#%eVSE9iWI5E# z6Hg#y6875NMko?rr#x;70vplJ{?`m*^rsprAH3)2{p*gAY&6ywIt#NE3M8{ZVIu5M8pZLM?jkbyqk3a1b`{+F zNH)Glh*K*3nnDP6jBQZ|D=EUjCR!(YvjSOA7=w zj>tgro6niLfe~35h_Ts@8ToHJ7pp9?#Ppu6PrY!U)fMPmTW==ym2!EJ1R8olkrf?~ zKt<4L)kQ&L;(bjZKrFVGKDUxAl6I)K|4NZw(flEyz~@&WgT&%6f_x#oKk$p<#}ofz z|DXL0xVXF%5PAAGiGu&lajv>(zLKEr za3zciunfIyZneDvUKdxyF#tkp8G z%ntb<8YNS6YBbD)k^)!{RxW=DlRnGtq4G-14muPqem;cWlq^Y{^9@B%NFk&57730j zOcb9&UIIfwOKJZY{N(6=m~>@@Hp~E+g!d*fQCBow2;n|A$(M^(mr2yslWj(i-%;2S zwwJa84pM5%P46QIUcalhEKUX}+x4VYGU&i~9Gi0c4fWH7NA}ZaSq;xYK;kVFB{+P{ zXWH2hKM=TBt6#axH(@FByEtu~c>AI5nCH9xim#OqC?%-^3O`Bc?16R^TjZzuQXF=$M5i0-pb}+uyy8T_igH6mwHIbSJo3R~Z*UAm(+h-`DtmZz9 zGMSJuU(+fl;S3gwpLJ89_W)G@eF^thbc`jCl87)tOL<5hZJ&X)G#>d56n$n%p}Iz3 z>B9~`V}K^tMF{U?JfUXkHIxKY0f89cUVd$UI-!fQ#1wOxa}`hKwfw?^Yl~yl#r>`l ztTAanFcXivcCW|qoca|%P+@E2$~$A9TACeCo8={P*PH@ z#m_={RoOoe{q-s#atF#}>vZ9oDj)?7*{-qTSXmuh$?#cDT|pe{d54KkI}92s6IF$0 zWUn&%o*P#D`L4PhO}FTSv}>j%vd1!x7(X=C;zULAUEu0!5mn;Q8G%kbTs zbwOo%q!{qqFg6M#P^Tt>?gVUcz)?7H;GS7fxNuFJcv=$`Sw<_X6x*ey6`vf4=m`^6 zXt(I4^yz)?p`o$A)Tsa!J#*`n0@(goAU;NggaLJv_C5&&TCD*K z0yU9(vhzsxazjOBOANp0#SlwRoKZLWmiXCh0>-^t(caf0fFk=rlOu>aFgnqpD+fgC zkxruuKQG7drvGuIaHF%JuH<+w6+gP7AlmE1zP5754reFr8E`5gnDcZ<7lkCA=4uU| z{Ilem0=zpe!PdCV^FFNoXdu>cDUom=cER@PaXi7l%|sVhSTpalzTu3?VeiS5+N3NG z@G3CpVx7PrMjr*t^K(5}i}C#eT%N3R!18jsP~fkGT2P0Umhyl8w7=!@E5WU1KEH}y zPk6j0N&*#QoioRRP?*I7ArxA*=3`KEKYN6>YnDc%7!$X!ixt!p0HOHs$DQIYhKOClnx09TO# zD$+SdCRI*kv9O47XUt2sn%^S|>ScdqbCj3MQW{f)KOM;yjxD7FpYEzZZo|rq*Uu~b z$6#IYwZr|!N*{z53lZltsQV=s!GXZBsfoK%H_2a(#`+#Ml)|aVLRj=`H(gfK&Cf*{ zWEq8Cku)Pg{;R=T{htd1+~pb?HN3%Bl%=B2%Pl{V3=mAnefXmY6KX9%_Yc{q6f!^t zFms#XVQoy)I`RodO%Iw_<-ikars-2@LZB>c)UJ?jq3m=8;}Qy<2Yn1|jcc=%t{9Kw zPH*_e0;O{8-fn|43nk{AQ&*EXTtmLdf}mU2yIun97U%pQqZyNvtD|lN-4MfoG7k6# zmK5w8{1?Y>>=R&quR+Cu}|Dw`| z*HQwSsia$=9OL5+>gP|dD9AnkwnN$!uAFpa^C80Jp^TN4&Pk-tGQel6G2jZJE&0K^ zC}U6@&=IOBI#nM%kERas-GI8-A*CBH^zvUN#`+Sd|9R>`SUi1{R-jEygFY2$XcJ_l zPm>Ttqz_YCChf&;1ig)X?sn2LOA*CcwphpAgN+tkt=6`I|e2BK+q@h=dbZYhA zltWuKEg(ZE)>r>~ahnQleQRneEQR{!5~@ugkT(~*0H`L#s^WaV6Af^^AW2hkT^sjU z8z##E6N+)f6g$`!MH2ES*Ex~} z|MHme6YMRN1zEO6^YT`d4yhOYfRapUrKssZ+~xSu1dM*7beT+ASIPE-8*7nkwx1==OO6z zbsL+@|Hq58^$$yhff#;$NLD?28Gc#aunr(ZG*Vukcw(b*DKJ(&IYOHxff5|1kPfZ4 zxYQuYoAc z%=D0|g8^()u(N1&zEb?BvA%ca>)k52*_bBzw7TGx%e2l`yBKvv=sVzRfzgFjOMw$L zX*tSrz081I&W4fqe+&v*kdR^T0&zq!Gt7QHW!`TBUm(4})B;+P;co2ILz<-%6d z+~#*~%g|1Fh*(@WgP1*4A5l_xPhOp_wWQ=Ym(Q0E*{$B0N8a9a#!RZI(6kkxnUDVm z6%vF_z6Yvj)8%;3FzgWqh|s|eNJ8{eGoLl#OVM)0DceZe{N*?+@w19Sl(;275O2J0 zq>5vFMlA^hdqyMma3c~Bh_>e)FzrzlO8;NbUE2&d-RfC?DPpf5ZyEnAo}hQBPF+wD3q;=jS6 zB8z(Vv{SlU;)?LOkifve=FthikCG`=P>7y|6y-)>f1>{qp{6l!O%R>)a^B&i8kR@z z-oWY8nAs5yPaR+537~r76tBWM#wR^HnrF4KsAYw`x?kSWeB(?R>~$f{^9;5Wh{jA^Pezg>gjF|7DHz=2-LddSn#6omcbfYMDRC(!%Jm+2J@$ zNmeCgf{h$Z5j~6X#iek!r_;o`C~i>ZUON~-7uXqC9`zw6hf{}d!Vnv86KiW~#8R;4 zL4&P@@+zc0v~_9n0sS6S?e9#;Y&ZCT9f80LDs;EZ^Q54@T%|p-27T{+PGovw{R{K8tppo&fW}5XfXd@7MRr9-(K0;`=x?2AB(Ib8`+@jVvlD~QW{RY9n81Q$?mQlu znAST6)T+}wKhgg=^}H-M`p&l*(T!A-RN#j zjKiHLlAi^$9vRnA1xH?Q`0tF=f9?~Uwkx?hQ}E2-jTF3O^+o1>rq@|<(k*3CqWB_` zTFyw?)Vw9jf-0okuQvB_07GYl%) zQKUx=`Nl8gU?N6TyEf_~>JEA0J z#wI;76u6YSy%z0zCUqFUr0?f`^WP<$n9pCr51)7JsidwD1HouYGXDTGB3Q7-dZX3J zaed{E3n)UQk(?qnvCSkIN^+ydTED+Ki2qo4UrRQfdi^qV(P4liRuFOouhTOS{KehD z#n&|~|4qWOv}P!arQ|O7hJJ#m1NQY53t9F$0c&MGt&TlYYs3ae59pM`jA~7g_ifzk zR@p3WZ9|eDHX2N2FQXsMHlr=XZ&ppe1iebNp$V7niBp4fY1Apy|QcJ z6h|w--Pd(zbqdFtW^B`qBl@ipMR%ujh0`Cf9#iL_jZUB`WsUg6d3wX%49WtQR?yq z1O3f^qUq{~GpVUm!Bu3%R^i)D&3IjLs5g;&S_KN6Y;PSiC1^ioaA<%lv@RkO>@D?W z-8vZ(i;LzN3G6Uf3a$VH_!lE!{y|T0#IRx1S2*uqe7#(lRrOVZfSszSK8dwY><9=@ zO5jiD{4Lj3vQQF`n^QA?Q3>YRC6U0G+ah^eDC72cKAxbB|K9hYQ`TLBy||&&BDU2j zr=b)ZN%*)~H68%+B?gaF;GarCwZr##p#Lr0@9LcoeO#B-%PVV&wP+7XK3bdf!;p7Id!wx1pdZM!O}nrz(6Ov=I+k5)b~)WPPR-Dr zuIQf0$yXyeX+KNw-Rw^=UiSnxDfE%aKEoGKLi}t7(D8ukzYA390(^UMeblf9C4KrFGAL<+H8^>9^7$T*o8oVHHr`tjR(Sp-%0a^$(5ZHwUA zhw#Yw^~it5uDW)`*{?0rYy~Y@5Wc>9%^E4dz!=|U$Y5+H0CfF=Hf4#E(7seUVE&sqH0A3;?eAiq*>uyE(E5C1n+@B?k@1iIjslFEFd1IVPNd#@ipTod zOJhM(CzA}(U8{tlm>hfQ$M_^i9du?kub_g^Eu3CFRqEaKH08UvGn*e7CXm0~^UxeH zkq#5>2)uCOVvCb}hKBb>RX1)C=>lfa>#p>VNO%VLC9LWnl(QX#e!?uQ(?Z1&eR8nA;$EWyxU7w1^&y%@TAKAw~%b4o@%{t9(^O z{&hN?n%&s=C3H878xGI?9kAyvv(mcSv(mG=DtA!%Exd02)H&0DDrjpfw54Zjt3R}7 zH6+0~B>kNA=Z>=6A1gbG702D1B2U6qlHrG?#jE#hQqFO=PL3>^+JY1OfnHbF`dNDW zoLBF#AYgAXIbW1?FYO&?h?L!jpmU{Pyk#*}cn*zU9| zvGu;J9J$WWIu?eIq0nFm0m^W@d}6RXlk&q2Wa&;oWayr=u!0GqHs$u3ugM1h8iF zwf?7u1LO9ev`Cw??Pj=9c0_I?!A*7x%MALjbkbq&O*6|%!4VaD6W#EHB%rhyL#41nyMoPB5NeM~|-hvh-Fc_--=L z&cPIjy&jY!C4U;Dk9%&G=*Ar~_S66h*ea;fRBrjhudmUE0!wKzj~WKL z{PIL%u%8@LJ#Mw=BW;0ujj82v=@mCnNSx`ZWk((oLkY5$(Y8+ zsIP^FAIKly<2ChZLp7`8+1~)Ab;q8p&wxIj0%xgftD?ZQJ#z8sp8*@m*Hx^fbIeBt zxmXT09C<>aJ)j3jj~@xtDF^EyD+8vuOyIjBPcU?dC z<=>gteeUx*=XvgVoySR}Sm-CACEMOx=$`{~AfftYIOmqQ^39ogCYKP|~6Z*nQYYNuq*wp7Llfhoa+6X0Zp#Ku644t%9goOq*N_eCN)4f+XiL^5S$LbyjrS=qga zY){0ardC)*x&P~WN{+e%cn+ch&xPCrpnNq5&-tVaK1?8?2Afej7DEqNt%EvdU$tTU zaVM<^3$T;N!V#J^L3ski`oplRQal=yO6h-fnB_{vAmNyRYWnX>D*&r?39Lc^p}!Tt zrIj9OH5+4$H3$E-wzp)RMnusAfDCJ&bj&K_bizit?Vk97w#}V*r_2y+7vTd{E^nmM z9z-5``j4 z3Hm+@$qLxR1iP_GN7Dv|Bq606Y`^;*#re{MsU<5yc5u@LPuSg*J@NaiUXG4|feSk| z%N~uW(8n9|{n>Y+&pf;)GP@InHkKoh!N~h>LRWi!AQZ{=Z?Y&~zakdN z_98ZU{g;!F*kQycyBo`V>s(F{FXa-A(sLYqZ+k;&_c@{qIvSJL7>(O_oO|CN=VW6J zORA~p^N?Y2h4p-Gm3x>l=y|ZS99sKW>|nw{#-Vms*+Bl^i$_;!+c#+U{x@h*bfs&RXD zyK^*Q1FGTN)UZM;?9a?%YjQ~SzhVVi96_PD_Kj#DRDa)JEw5=>6v{Nxf$`g}PD~wu z%IO1yk8<%^3WUAEI>TMK2g zDqp$>&FSaPzo7VWh(ufiWOW@-z6k;m{9nB|Y$NVaGyQs!KjbN@qzV75Z+%qgJm5ov#UYio-K zUz%{xCZej6Z`hj}G#;q;kVWDGJFUzJK8Gjd*w4rS-df(;zAj;cMVwzDY zaIC_rE%KtHbsFv3VHbkMt=sLXD$ZlQkw2A>f1THHX7GF}I?&|uTrYS`vS8zs9+)-y z;ER0j_xN*pSh3$6LVfCGEWlTbx%H<@1{Pu}v6!OWkq~opp)cp&oW>v|2IcBbp3Coz z`$^Z?lhsLRJoCi&&4-Y)QOVN2nwx3LcVBp%MlE!@Nj1eirPS>OUyc}qEW^KbPma@W znqEDEAFdtVgOFc(s83ab(_)mUi@)<-6(5v&*~-UBYc^rOJsI}>+G^+ln`kczhfqk= zTv$>M!X9ZAJJW_q*wdPJ+btA(iY5V1E+)S;avKvz`E$kfo&sQ2Q}5ef5u5?vpyQ;F zgOd~6Q=_Bynk0+((-jE&ZZ0O_o9TbzF{+-M7w%M<_3awibHzoS+qqPI{({<{;c14| z$N7%0vaC&6FWA1FP1QN7$EU#6d1IN5;v0|!z8nX=Oor4F)*y)aB= z&er?1*M6A^@}Xi8w)yppTv^XHoIcA46<&mGowWNIoI`gnijS|y5W8p}GjyXGrXAt; zC@fO2p2aEyD87_;`wd%(lW(KsNC0X7+xZU7xO1p(0Q*p6XHy+g0>Ntu?~C^*zqZq} zXtX)E7ghWg!WAage2$D|dXM%+vDwt}-k~D&sipOah8rG5j=70UAqmRggMf08Q{Zcq z)^X$6&pOePUx4n*jzw`?)u>xKE7ZFu88qn+mJBIP1L}2qw;5fk8MR7_HE@XW*&6U zny^qi<&{>x&e_Z(8s`J>BTp9f;?fBr#bAqfsQ zxI12}tJ#BmAH*@e%#PHvLj=S!gpsp4QSEJEpBSD1sr4o}Z$*0Mkg2NKN^ZWUF z!-Wd#l=Sz*9#uMp-eSv@!}zL^CHsw2`Fe@KQ%X7u=#(@msEJ9qGYQX2I(!Ao_x4dT z1^yb)aX&{EuoXUE)7Kp$ZF=5^k2HF%1$D|N_7gr>o2u+_{Tj(3@3dK@+1iwA2uY1W zf{gtly71101NHg&4fd-o-bZg7JGC8~EQtPP^21zI^mxc4-aG{tgLBg+Q7fWFk2nIm zN+gbCj120&;f&z$>HBkFLl;ndYlJ`Z{YgM9O_rOf#N*5@$><@QKdhMs_bVr-4gV2r z;!M}P{+_Ro8+Jd1Y8t>6OIP)Ht25%;nU((eIXp7PqzbD)7hzsKfe()H|6_>{zACbl zIZi$yva{t=J1MNPNn1Ozj=kfXP%v!Frydk^!1oa=aL@Cbd4DQ0A`-UB3!42D`rx%F zuZ89k3(P#;_7;;+Dy?2kqEbf+)W@IW49NRgE#!R=2Vc`$LDkuk!f{W&M;l!tNiu+9 zzD6z-vkwnbQzGo42HBgBbk+JlCk?-<=-&PrAf`chYk6FPMqsCOF4U4kUEqyh6F7fmwASw%edP2t#jWUGNUIQ!^QT`CT4zKjac8JA|XVJ!utJC>&HRgmRM>*u2ZAU(X}iU$)}$1f&`c zIhs%fZQXRw{v0o39{jwE@b&D%~aqeOA{o0N2G9>y2bB>doAV zP#>6!@jCU0 z|2Ga)Rjl(#?n*5MbxrF_#x)(_pO`cWcCm)n&JR0j@42^p^Jh+F$Y}-@%Wh+?i8wc& z^|pL%6J7Z6#k12BhIxDyzqVN)ybrircpJ@15X}FYV^jOcwnR^LP3Gdg`Tn?Wf2E46 zO;;1B*UPh~+b;LSdgO^`o$Cp_Vf}^lb?fp%`)=}SRgW>RhEy{r1yS$%x(UN?Vk3%~ z0-BgKUk%-K_^FEgcY-hCD09UaCR(d$wj}i2X?h@=jKF)Sr#?2*RF>vzC&-S^@Txc9{mPCiu(Ei1!AH$(!lCi0NSpoFSP`tuwOXF^~qP=8{pkVOz( zi97NeVNXoZ-8VtEsq9aMum;9Sw<+;-w9fjU)Cso7|A6O0Tsd#avphB($GhvDHPN*= z_0Ep_VXuQEN?F7?Jko9LeDLy?&5S11p+%*4P- z)rR^onFkOLDJ8$|;DR{#O06Xf-CNuW4xHS3x+F&<_sRaaVQJ2pA$jbcd&-$osPRfy zX*Hb?(JfvSbmQw&N=TjY{^?ECGY!QTxLu`0Q|0pH!iT>kX<%Mz$p=1+ofI}jW;b%^ zJ35D&pS@0KR5aFpR&G&h#ioZg|7LQ{Y(VoA5#CN^|7A<+%St?=5GIgWacaB6Q_tyX zrr-635!N^|%BZ(W^3AcAO~;WcsNtVaOT?-(&pbGHdDhjk4P%umd?n$ed#o0r({IA- z_f0bP{3q+TjoW>!rJEf(TB3q;5AI?fva$jvuP8%X2l%IlRjWw-{+7das^r&Y6MkUt zm0MHOd7H^Z0peEy^edzO3v*ZYMvo3*IO*=9tYQ)f=Ikkis0mPT#2J=S==ca-=2B_%bft6|JDp)2J&QM%IRA}-6vF$r&c172U_ z<>{s?#JP*Wl>9SeBV77_$NcU@Cv>D((1za%OwV+vDN7Ea^F3-alh_ zkr&M0XS2ShQj!~uTpHbvJe;ERT4c0CERa${V7;-odHcKHYyx;>Wx^6LavS$0{vOMN zxae2nCf=oMA2@c*u6{pr*Q;wGa#~Ni&G)kNiSd}zpPpq9IafP=Ns;&xg5Imsf1CfB z_9^$wjqpy1=Y_vkczuv~$=lioF3Umo!`gj{MnB%BVhDYUOTE4NDT|k)?^X}(FP5z>X@8sh8H2~V zxjK}QPu>om_H9d4xO)dmW1%2OGHy{|$FZAoguf;`HR^Tt zP|e-9&4Rev`h~YI**I?>u@1gIzkM{-rMycel?4qWQF{^ld6=N)49cLVMB_3J)fPpV z=bYGlO6=h&M^2ky%*9Be9r4dr)=_B5;2?2jHE#pz1S^QR)WAX!%78!hU|mC*Rg<`( zpO$V97aeRA_F0y{xN68XfU#TBotr5e5P>wA0x!wG=fadmz_{a=Dyu^aDF0iy8OutdY2uT8$k@I@zsOJc%Z3+d00nqwORJ6d(97?$-snf3dM84N$4 zxW$GX$X`Pl^9-%@8;(Da%20`XA+hf>D$T1rtc6LM^J?M8CEPL;lH0Yi90AffHcT&< zhK-_QF~@8y7d1Yc?7h~pl6NZ@dEz}B%dm1u*CW3AiE&ThvB~}Yd;7=-9oo+u>ux;JN5A^S?EQE= z;aC*;;vc|vI_M*vfRR8{fYY110^Fpmh1tcMSEW&mXG`DS@WDwLo7Gx+nv6~^{GbD? z6bkyS_t*=18p@m{TLy9hi0;Xh(614(H#FqO{Txr2sB5Nte0Dr`G0*4nKj5$s;!DXE zUYdh)SrV>1@yg!JSW& z_Kdy2G1E%c#2X447!kYrp~=ZFF^toNTs_`nZ)-F!le9TXn<-8UaI0Kfz53VOjyqka zf#NmUr;@1!5tO-lk5@m|gwBu$nPBKqCXf|oaP6(kQQ%jhTib?hbq^s8s?J^~sBi#H z`b8Ypfz{EJy#y|RRYD4G?e;n+;~!^#k@yYABLfROiLW#k-(y-{c#|9X2)siU_)@vt zTYb>u(&XirXk^(gs%!L+`?k8e<@1Cxc4uTNLCi`0PafI`_RL+|XI#X|=Lx@<6Th0~ z`pH?8)`aj6#D}FGec0ASbXD2GlStfmthRjaR>Go?wT8S3b5E{dpl0t!d51V`EsGL2 zN&5^Pb66ZD!7J9y>GLKu z7=(pM@9&mm`Y7oiuQha0l*o%f`#0-|h@fCau>Siqc-2LJ#x(QRJ?ycU_olyl7?{0u zCGBsdXjGHRBm{`n-%nXS^tsl!tmRFTZ+=*IN7E>(DgEhD&`(;4EaUt@=H&Zd$(4y( zm|}b68^7RsJQEhsu)XeXNurK3%o#LtFh=aj;oXl5rsKN9)Wk8QdM^c(anqB57ZM#4 zj9<*0YSE6OpH6}K9%a>qp@!W)uRA7huXP9vPyGa*u1g5of*P~~vjHtt&E+V{%5j&= z7@5=HsKhnPJ>|0}!}Z70eqZANwDk2T3nw=H*r-s)D9Wv5YU4RV;bg~>0Z$w5C3DX= z*h}`0GM6rR3LHK&o}mFfc?HPElL!H^oxm<~i!bXujmG;61x-=_vfm^J-w5t!;ctJ5 zV_|OzsaK+2m%AGn*I#o+{Vc$Odsn#4)-dGSHsi~R2ZBXxIn>&GS-atDID=%$CL-vB z7d?c2B`ko~2``Bfk8cW{1>9UW`5bTmk_add$H(!#%&1o2Cc)(w7~x+V_5K0HVlTtB zFe1t%k84`3ua4BfdY>%cyd69eRlMe-wg%n5+vyXnsao-rXj$myWNPWX^y)I(}Uv#Ggc+c4;g^ z3fLya|FWt7U2-D0qwzGi_YsHU__M;_d7M4;Wv}^$g%))PliaHVzsUfCtkGo+XP8(~jJ@|~zS-KwIc6EqLz%K}M?#Mr zvOeRoL?g`!jhVLVNCri`rigJS@6mp*eDmDWofG;^nvtS#n#r#zY~D$k5W3SAA|<@5 zK|Y~RU7!62w4(l=Mb$wZ0tzDBgOz|igQ{y6K+;)R_D-7ov*gYIM037!sZ?`11x|{& zo}1d-ZPanIIO^IE!6+8t+|_*r~5S984BQ4Stpx6ZvB`dm|=R!1jxc zPod$+JAg<$tNvr?`Gs>RPsPbHo6+dRkuP5=Q~kM#7r`1|J;IQ9*r<7!p_vVdV>st7 z@uD@7Phn1Q$KpiVLj#m1NsT-1lE})j)B9<;bb^iDc|3KelPNaZdW00XDKn=;+bJRD z#!u(;U~`4cPw;D*>&hu54zDv3!$Q*Da~DFZc$2Ox9-rc}tVK3D0w(nRJ|+|5;pT-$ zCMwv)tuQ(~oFvmOBQJ5RX^+FJ?F;TB6`V3(Y)`Qw~Vy2C{LmDe;mL?wcDxhhwUtZ(iofTB4X(erC$%v zZ(Bd4|Gj-n$PMfel2QdlwHs_CY<9kdzyjv zvuN`$TvB>j%WM}Hi3Xc{O*hZHVQkwU%_k>?bn?G2JHA!O3!V}CLRZ8spj?^vI3doJ zimy>$;sq*%m>&gADzZIAhas_>G^qortVfr5tWM|=DzO^vIuYnRbSnHQbkMSnUS!Ar zHjTlCkdoAimmV$(lmO|=Z3t4qd5x{~z#6M;x+mq*+;*){uK{3LMJU zqAv=~rf^vNXe@hfdL!TcR{K3@&uD$BxZ*wh>eE0(%(b1%*bFrB*fk7DnyCM;vjDsg z7lw-UBv-Ds->(i>rUUcWUb{FmgX02AL)GC<(GJ5&Oq&l)!)AVeOvbCWep0O@3|5{M z6?!^Ypl-+n0gm4cTw(`u9Yj0HQ8qn2WU-ig4G`~l_?A{8ei4*eYiq99cZ)q)8`baCt@NugouQ{OteWV{s44w_ zocVblVG>-r->ltgL8-sclP|zR4AV|m2H;?Ac4SeA9jmHx-&_e@Z6A)Vh`AZ^sFLB@ zbw_4R1Q#ObpT4%Q|6^`{rN!Pz{#7zvq#aDwaM-H8 zmf0JyeOvx%)>-aKbb91(QRmo)n~BH-pLo^JVkg|bYV2u(eBgs{uu#s67UcdB-y!GrI;OjQ zqZ$FdYNXhK!g%}CrFlOy<(CZ;{oo?u7GCckYDesk zrjg7}XvURpoPS6&8F$lpvRTa-R((H%JRR&JnynHtwpAx6N2jR`M`jJ_?!&YGE!l-wiI4zu!vAuC;Vs z7YjUjqEThS50G8+3LJikwNeB1UulPHgBD_e-h3ip#VcA&4tmMSk-hViG)(s3gUyAR zUt_c1vErW@=+ts91Zt*Y-x*Q!M#xdxm=hfcjD>~87iI7W2>~Elt6TQEH?WNSp?)d{ zBTV2}zxsAoMa{Bgw=3@Mo~3E%@0?cZM)!WE@Tx|81=&6jLcyd+U0chj1Dp~l z)w#qBf-TB)y`JV5?D+A)sP+%)5=VbC0#_|erfzFq+}>1w96?Eo`h3+z>`Bbmvp=?K zO6&KkP9rI^{3jyN%B!;K;;3Twn;xf%|LC?K2zu^}Jz^awKb2moQ<`s9s60(~ZS#b* z@nRvYOv!%t(=pU+e~V0}j`+Y8LjLZC@+8FT7jc?#ZMpgV2HgY7RA}tKgubMeucN+8*jYkaElqb|mFk`kQnR zi$^~4)S?C|ptAokf#9Uu&qYsKs{f5ZiAFurUWF%0lJ@5`U+{aBG%4yE88@?cc9lDu zH1OeIe_IN&J@<*9OIY*V+oZYgJ<{B1&H>w!JK9~yCf7g5YWF6*8Em&gTFsm8bbx*8 z9CBLG#)zg%)Za4ew>o!=2aTwqk}sZ5d1mrsA}N$Bd$f1x`l{#?otF;!-3_wNTS(Xx z_r08{+iefIh>WJ5ud)7!O>~y~GFmnG_TvMiD=f7QENUDbSJ?c`zpL}T@LNyrWT6AC zd3%0Ab3TMnvwPR&Wsr`d${O{`{X91UhY~)tF}#q$f|yLtCd1w?CY$W17VmF1`ppum zX9tfW?s%@a@~T+-r~dgRi{F zy!!aIPpp3@Bhdf+yXpl;oxy{~6VexuU#(i33iynj7z?PcRtX_cqlghQej#}t#l%^E zL3d9VznA(sO61$Gcr_4$oyy~+JLwq8cyH?b%C&%%Ny^h^3f&bTn+P;GO37(O_Mu=`B`)#PF^ z@yXS1R&M*x#gvTA<&2q;SMSUvF#}$ODzRp2wq|t`D(cE*3XCrHqK~xQYXgiocZh1ZrE>86u`oXHa!4TPr|KPaTp|l& z9mz1JnMr@o^!CW$32A;?^V#37GWNdQkwNXSw>IrfMfMAK>E@4j|Jb-%{b)+!0B8S4 z{p8Xu@ap{gWvk`t%nb9~Ik)h)_R<9P)!HK zeU?q_FuSckmvbD%ObYt~3Z8F>3>e@h9MHHcS3=Heu5Z2eqNs~1#9nN4k-w)whR@7n zh)*80=L_-5ZzBw-8g+Jb<;_Zcj(LinM>Vyz5{*8SW`b>#pa$Rc$Mj{D528W%eRs8= z&tF*DxK}Ql)UKs;*zSVME-rWCgD_HfUYar=A1Ycw^!k$MYhi|2Qy$-fPR%#a+8?}O zA8JUCIPlQ*fAtvQ772}IXL0qV&$2ExVR{tKya6ogeN9NClL(`Hi^)CL(zP;pHTuZB z<vD{4DvCS97LkL*BYEFKgqPzC? zKhej*6zdrU-aSc!r^y=B4+1-V`}+k?PjEdAY8Kexmq)EPwszVwGpfg!JB(lA>L-i( zvqqr3_b7>CRyPsTDOXSPJ*u-}}iQDGTJ zp;a9PpB}q>a*Y-GZo`lMN2$S{?J2T~HSnOHt_j$8%{mWPu1G*b1qyB@1@}dvnS(OM zwW*(8AhdEQ)LU`}EjBkEm(}2(0PUvfQ6BCZeyQ_1~Q-J?`vzsbs!ijB-UABs%zCc zETnjF1WhZalMd!-wa1+8bN#2noU`-iq)TevUaHR@Pml+hNYDAx}ILnZ;zTbepz0 zCYEXU-snBnVTTKRX7^ZE$VL}Fzi`*Yobu`Kn@c|pz9whAZEoeBPO1q{LqDZ-b{bmK z6rbWpNLROq9Hs?{O~docyo{)5Z@LDfs6*RAdj_&CCeV-otyLMkcu`sTAMpG+ZJ$MX zsUSL%_GgCYz!4xxGWG;aP4p8kt`TkA;e77d)-ETtT>3MEJRoKzeRN6a3ZC{Uz-KfO zYf@BQO2+sCylLn#%B?U;xSe|3$U7~}l@w(L>ftwg(PhruWPl*RdfJwvw*{0*RvU5A z(EHSZfdLCk3#%kUjt~J@DyF%pJM1&5!{EWG5kyIStC!_ut~YG;@@iATwIx}jN0d)X ztTtfbGxi@SM~=8AWQ2v<=%;f08!NwdB;8SdY0~IpAx3_k<3L4R#!~h*VP?><#w@(D z%pVGMjjX;|VuEKNCq9o%H3_3MD1+?9wR=w*UG0{H^vB_icjY#plzWb4WoOS?`CW`X z@l|Lh^j&e3h5TcOVr>jJn|gfiGjLSsWHQzOZ%AdA3d*>ZMnM<^WgOam!@&6n@9xs$ z`#_b@7@67PCAaCd>7sBxPn}3n+oqw{>%1b+z1Y`=^1l(i6_Ts0(?We8R#ufn)!@?QM240k;xD0FgQ)0S zI;Ld-dezac;q;QJs#B46@HX44)p0s^HZ~SQcS_3~c0&61wJ(!K=zQqn_-WgG{queg zoz8~*F}_sTlh*UkGsoYKeU2sD)F`J3ox5_=@T1Rg3U<$MNgr0A2SW!+5XeTRMUOLr zfUvCAlG_^={t2A^z~=;A0YUR!x5q>G}AyPy$aTyDUX-*9i39H@Smv$IUh z*_>W!^h0u)scC{c z=p+C}Mxr>HTuo#WoAqLIh0;e5lNP?)h!%@SxOW`SBoOlR2o0%3gImJs7r@_e%`~*K z#U3*Tu(ZoW$D5Ct;(0+M%m?7-rLJO&&kqh-xwF>H^(2eVtu6$j=DG$uZ-@#Wh1B?F z$;8>#{EWqsCf6(#@ScCdjvjn+9+ex(?+1v9&5HFPBY1Y}kr6f^1%B4Jxzrw`GVm71 zYgUAQiN#0q~zDkGMJ%qpDU*r8Y@(%JaS#z=QKJA2Q%0+yxCtvi&YJ;gOf{)(_R z@1{cShpiWpEQS1KG8eAK?j#y$eiys0w`AQU8b6V}SmO&+*#rUClbG;OM#zJGe)|(4 zManXL!1_Gs!e`y1!CMqNzvW3I`CIZ_v`Z5miEmyJo8nEaGnQPUcU`$h_Jbpt>o z9nbFN=jG)|hHBvbL~Msa{##LsHbEI1wISrrw7a4+nMbxLpPz{r7(O8|d;@JodzICs zEk9dS5jmz=Y)t4yd3vgk{@@BTA|yk9-=Wi~#H`BRtEVeCq}13=&fLgH=Cjy;G?rw6U z%O)T?CdASx_9isLyd((ihmGU` zJl)rzE{BwjudUI6i>QNry}z8QCoNh8KVW&iI>s01Pf|4_#`puUYMQHGLU0QoQOqPVK|41EJ!YPgg3EPnX3L|q!PSry!nB>`4qj2P^W!rp0yuu?zIi*)Ty+ZC{fphrfLQ|#HT&M zodLp;)B4V^t&&r9ZiQHM7u1VxVoZ=$i?2+QUgisY&_<9=!ze56PI7imAU9mwFkud0 z&ntJQ1dkOI4rPvnx-z7J4@T52=L&0EF{m~Eer%M{U#4MDEPhX)J*0by9Ph_U9xDE2 zN)f7vQwxg4;>by*%<3cNWz|b$`S5HP${L9?PK@$WHSV;8@(`k^jZcl4z6h33O|mni z-lQBh{Ob+6et{=)@MmYe!yZzcG?E0B2N1m?JH2qQJ#!eLDQ2rhNCswIdSPI}_?t?^ zV|khtS&-;>O5F`rWgu(0R#m3<@g5`NMf#-N380&$)$=Zs|3Z$@LzQf6jK)8-CjF%U-O9+ zCGgh7CSp`IXXTLP2Kg^lh6%KF2#RIlSBK~hAlke}0+^PkW3C51fxktVjI7S|K;wbg9_wNO+Y+d~o=)(3OnqyWUPcVf1vRclvZnN(jlVskvEvZ7j;4Cz!o@AP3qCad~sf`GYe@G1*aw*s}w4Y zsK2G8)v|^}AYSzQmEnVz_g7a1k zx`VlYO^iLDOkKORDCES79-@~6Z(T~fw%hitEq{8aGcS)I!enN(D@9Dl=fN8<;4-N&8?cCpqFcs+B!p zCt3J)nr^+rO5IKlUdW=jJ=%1wm?tL%o7JJ%{&mz;e5S1**}8HncEe9z9kPJ2dtP@*N%+Mq zN?%`Jq)JdSzSNIZ+DV`EzL67(@8}&~TP#TLh_T9*z`h(UcSPP`0g;~A7$iy@i(8*V zlwgO79?zp(QYa-!hX1BuXDv*cd0prSeDK!B!XUA~O&NTYXt0LBWKm z%M(QT&cP4mD}1x@0XWO2KkPW@4+Xd-zrZ5$fOJ|1{GJSdcejgMx;YLy7lX4Vjn=NM z{B`9tCQwGccz-=TuID>N$nqY{I@=97ZE=};&&$heqGH}FR=ZtjM`qC+!fuQVLdUId zOwRhqA-u7bAK|@M4XGMXM$!@se2>M6i4ka@tjk;;tl09+qdY(VrT%I32`WDVmMfxh ziRJFG&KCT+VDuqxD8Y#E9pV}}S{i{|6*c<_bRqPN!Y}LEhsdL$esU;VhZ|SHURY{u z<7Do4gwgcGlogD>?EO!8PR0C2HACiK5W1@hsxAt`EUZ+>a#cXxd{d9ySIcFfT#@W9 zNSsZhv|04)ucP!IdwezDsKO*P%79Rp!k>al_j_2}PB+gVDOC1xVoVgp);7ql%y0D; z9Q22FV(koB^xvh23o2uPzR9Uf8MU|i5Y=Z}L^-bK6%xf?;ckw!SJMu2Sli?s&6Nno zy_FiikJVlj>b?HVFJVp%wJB36<2|2A`S=6zvX{JH;wU%v5q?$%az2 z+h3tYFbDeK_cAGR#UYT(nJ*?TR?H8d0sdogN5P%eg@u7dAqP!l9)@fYnT-T?_OP`- z5FO)U7*=H`=yTC2t1popI!i+cSEoQ^GkZw&=DSQv7Kk{Jt7-#LHy|QT>*|(GtLyvs zc}c-A7(dh^xsw`=J9PGM0f(8&V=Ms3Pm&f?wgXCKytybsvM3f!+h$SzQh2Y0jKdFe z^;ic$+NrqSc;8jTb^*#F=}(%50D`Zu)~t*&V3TxiIHaE7jt<`q9Xk) zX`Q2w0WGne+3s2Ciuu0_4I(kK@HdrI=b_h8!(4IxhuhiHQEq)sE&$j|mj2Bir?mT{ zA&GZSh+Y!Rsz6Q_xU>4uo{17+>>+G(P}6PaJ;7*-ZF*4U*H)+C)zt}mWl&1(aL3ov zGeCHwS}1Dwk}Zs>Q1%-o+y9=-?MG102g-3$OA@8iacSk9$?krv$lPl-9OdtjFF~$2 zdmz{na-H}bBN|tfoN<=~4n`$VMrYadIt_qnwX9?Y={^ z3W#(F`fh=_FF1UUT29dIIhBT%aH`66$ZJyqO(D?_@!#me9XjEYS(I(gxFLi+1Nhel z(D@_QR@W~{!Kq3h1jHkX4=aP~<75KTuNdn(mRqTshW`-WysUe1cudtsn%K8$iphTg zvDFNEORD30GPXqmp)UWV!&v#@Z|<*;W&j)DYa$icAdASckKYmUvlz1dJ28kzp!0+L z(jTXK6+&<$5`+t;BYMD!`J`!=Ndr zttIxC(5j|Q+7~%h=dy`vt&Txa`n@GE37|H=TFckgX|hFh_EM20aub!_(0v8-2o>AH zhH1~zj!NdG2$?&v@JAo(KcwEgm)N<0fwVg+-%ZXQb`y&;HvWz{G{tK&o_9fLE;0Ci z3guVVqR^Y9(nDh9dIdDA4qkKf<6ZV&Jc>V0Rho!bikFIu_RHUkW#$Zdc5omQtdg?n zZS(LkXGwmp-m|7mu^puCwoog(N+LnG`vm_*Xe^(>gi9RdWg+6Z&oUqOW!V7T=a^)G zczr@FpHgV32KU%`Plg^V7m!_&wAa5N1nqX}%Bq6k-@8s@0PBzwmJUH*`eHNt5o=#t zYn>?NDnUFE4LawXsvI5ibY*RKguZ?TbSFF#Xe+m%ImXkP+dKbt7QlxVXP2z1pF;Uf z9_=!O-@_zqi=R<1;6 z8}$rv8ctwwE*= zYTuc};$-|Ez%v(+F*AYtRs%cjX) zJRwfrw2wyW!RS=mymM8qmET6Zh`IqZMZP#KB&slnqO*w_YbWZYn;U0Qq9M1!|Al>b zMo^%lhlSr^DW_595g-PNK#)p4ppV$v#sYe*U{BTM_<5L(oWq$wO<^nBKxHPULnJ9o+d>RKo>O{APr%qR^Xy!;3=nP|3X>jhk6nxAt4a z{;96<=$DOjC>W6q&vq}$MwVVu28iO=}U=ZAzjxw7%yDl)bPmdf;qke-RZ zM&pcjiWMzGRgFmBtD+2iuGO^(l8eka3#Fp*oko z#r4ps#_wrT-5`Tx0ohP6!Q$kLm)s#5sYCnZl$`jk0sNlqusr`pPi|eH3M+v1?R<&* zxH#vi;|aca-=o{X>?k*gh31oQYi&Z@$UrwN?gwluBa-5x@;41UJShm#f+rF)AtXbN zSRip^QFJx2G#ah!ghQ>~N**9+I|e5J27U?eRxTRx0k*3z-RV~#nNeIcd#WxhlA>75 z$FKGPIsH)albEt<5GwXB#vJ~?1-1WQLH+hD(E7gxb;kcCsBwPCdap`eb2N^8d~ zN;n3BUDkd1vhuGSfQzCIlqL?#Rc9YXT@u95>+kP3g3gxzi0Mn|=5-vONv_|y-yttBt6?5saS#$%Z?fQk;)BayEMFk%Tyxs2R_#YE za2Ptnpk1h-uwL3(q(dYPrbwZd5B^6?*P$);W?|g9g&@M>9t<3HMNZ$kO!a;Qb@cy7 zOt;qkD1K>1s4i;~+=*@Oo^^db&2irindg>934?-uq!CpU#viR^8}!q?%I7Mr)YuQ$ z{LfaN!)HRF3~bUN56Np^DZ};fXc1I`GW5ith(%p0zgU2d1MLrVkJcqcvbK*oQBeE` znEw&e@lA(fI%;jj3)%q1FNP7m7B%)T2upkDesR&1x`~P0i@WI97K#wWtuwO?qO>^_K9dd^hpGm2t(WbGuxkTvgO9(WFSk4)Ctp&nYL+=W2ji>Ae7i_xf zjpa(TV}9h^BF6Zd+(V);r@=kjMXcQ;vA|+X!be{~eV((^vJ~5_Pl%)({72*1kw&A9 zs7+WT0Etq+*qK{eR9PpP4*2B(S*}IwZ9QUc98{(!dnxS&w=f6desD-=3PTCil%w!5 zhD6XK{6Lf>K|5o4BeA9s2?SdN^J~hd{99s3p+XOK{FgBa#&6)JL{xEOHyqsCcXwD6 za$aF0#$-Zo8%+uA0qtinw}UjN$NJ_|HIz9$3vaJ|Np&rx7upUs+d+W+mI=Rbx29ZRt6j0; z*T3`Mz3#(5#I)T%#dH$Ht0FONj*7+fg+IkK+$f*8e~Rg#Z=AGO{98;H|4U4huKrU@ z2Q8#MAHJALxJzn5OxImhr7=I6|IcFD z<)32OSR$rj_#VIskJZq|?Q%JEka}&$7Sfdn_qq?$n^&G^DwuvvV?uiIhUz_j)x$~h z^(_~aJeUb9N!I7MBe$P4$YH9M^tEqa>C37*W-U7>y~Wx0@)L^#m}Ojm{YwFb74L&? zM+_kH&S)cRBcsFJy=V4H@`2LmCH9k?a^Z#4@ANY0aB>9JwmU)c;Ool_l^Rb@xe4=R zfdC}8GxJF&VZ1v@8)f&8tuNl}e@hL6v{8JVJv2f8V5+mQg59|F9Jg{|CG44iKWsik zP*C3y*iGCctFXKnm|+mjXk-m<)O(hWtEy;q)3l&n5Uz71oqi@TryUZ0c=IMnm6ZNO z9lFdu8D*9F8%i>)aBdoanlBfw!;^wcAdB;H1VpwA4lZ0AWFoTi1}Rw=`$iRNC!{+r zZCgk&S1hEMIc?$;^AC0brBWO93h7Y5(DMYbn_6ZK(kWW5hqtr}y``zvZ;btp&$z#^sNTg~Ae>mXbt_?bI0pZH} z0m%{}WER42Yb*0Z6PwqW9SB3YCEqLKpvbE}8ncC5FN^kWpAqi=s2Ybb=#0)+uq}Is@`XF|O+l^McTX9JdXu)s z8+moPJ68Pc@k9IV=&wRAEAIKx>q=~}uhx;)`LDH-m@Z7``MsyDjQ1>$h;&K!{52}v zMcP^VUWVYyuq&hL@Z=&kltv&*XAqcDj2R{Ap8hKp%~{6ZL4fJdIKwoBYv@ z>o{7#_Rnlt@1qc4zw8b_Wf$iCvFK(5U3gIEj`+S7>sxL>3g}Cq z^5|FSjB=vhyD@rTh~tUr0jE7B!PGtlspfK` zzZNvyUYMK_U`RXEof^UjanF9zFoo>+l4$pWd=!~sa||P>9%JWaf)1Z(O)1&TH@Sum z7!Mr#BPU%cId{Kvp>QlWPsI-gbgYFY2Xs^`grS`=zlbi*<1Bi;B$R*Dc_MQ<0f)U% z(@ZWhQHPWztSzgzr6%|6w-7g_jct`NZF)_N8`L`1^6;^G>s|h4{+Q{k+{&Ev!kua- ziFjXHY+ZRM^)zl7jO0A1voJ{Hdsk*w2xBI?hrmag(=S4+DKqHo_4=UT8V)A29Jf|v z_Z7G)Md|xrGmywoOEnbg9F70+L}q;gbP z)PIM11Y2*7QzPU8U^vxS^ChG=n%Z-j7lUvE7>KWxdA2?*6T=JVf7R4Lk^(ek2 zk1Qeh0{kE zv+d}L`dZm9OE)w5N@;Gvj%J1haaO(|j19Q&R)xG^4NKuQBx&qLo>#fD#5k9hcy9e9 zGIg0IZP(pKV0PXwt}@2g^dF@qFAi#)d=NT{3sYP#3r(R;-i4Wu=+@GVSEsY?b5jlq ze%(e=gASNCv@ilHB_-u0l9PWf7JM)w@G+u1(adLHTsUjm*ov!GYL_O|bE0|o6 zXw_x#EwhX^HPYER>`@2%4_`%={nh5fCKX&-+t%%#IIqi$d(V23Ja94AJ)ExAV$N|v z`^OPxCjUV3lkHe3c}+f@`=cscxQpV?}e=mb^G&APUT2#Af4iko+ zt2Np67jaje;(Pk~_*l8Nbgo+<+KlcBsdj-8Xm)K7Y9@z#ydrFx{V$i(I}>h3K( zUKqn)rA|`Y#_e&cXmdY*xvMq*pkR@tXuF7Ohs-4K=U$I7wF|-<^Yfgv)3_c4HudK(k`f&Urx?dmnGJX0D zjcF23-Hs~GZ!wR9N1fJlmyyOy3n*8}g6q&G{bH%m#9ZKcgZHXo^3NY&1G%6UnW^GX zC;^k^I_fZ^XBZl@!ax-aO*fBbWIt+{nlkH33iDwoU#ADruXd0(U+(E4Y)#{4XP(WW%V0xCz2q+5IXf5mTkO4zdz!*tKN`r2p%8 zV!JX(4+!f&f9+;S*QMDDzYP<7srE{}$ob&Jomk7@X^3BvA2VK%pVqX}m__UK)b*~r z2-}%v&hGYxDzg_xnOd04DO}q2BRkcaPTYiuqwb9YqcJnbQrW)^a!-v3{G~hk#>NAM zX9Wsp3`K{*&&8`TW8=$@q47iw!r~T~7xa;3Jz)46wjD;c@r=XhYm1{BM$;{fBy4`Znbk;DL(MIjw8H+7=DNp{+stPRtDQ^{HDkJwk1{oP+nUp zOuV2a`7j2zAcR4)jz0>Gg=^&^Tu@6U<)%h#ea*FDp7qwa*IVR9#v> zWoJ6OQhNA0*ZE^jg!#$A3!H!gN(h=Fj4Mag%S!(_?3x zNTtH@FpW=Uc!%Wa>Ap7|lNI=JPjN>3_Ijpj41ewiwY@RW<--$hSr?RDD%|YFJnFRL z^oMJP=&1?j_5y8ZQ~N7zZZ22JhNn4R4~#0!3r5nL-#Ar1q-UZv&;r}#L57;SAZu&h zVDVFOxZeQD!udnUWMzf2OF;Ft8LR2Z3=QIi9e;kk-y-NeBIH~Oh%j!X(YxpgV}j7c zqO?{Sn>%v|DA}dq0Nrr09n0)@{@o$m{$E8mg(Wk~OSdE%QmirXVfo)j72}mw#mcK5UsWoES~-^9Es~+=oC>oir^v!=o+OTvI2b zq{L4frPgS5GjbyQ^rjncpZPct>z+x6P_eHxCNVRdkK+ zSB6DrV8n7&o{`cD($aAWn4fX;b}1WTYT%Lzt7o4|4G_O}7FTZvlDuGl!v7?G? z;&kSW$6>Y!yCH<(Io>!0Gb{0<=|tyY-mN7DE$!ps8gf?ci*+ONRXPgjN?{vFt?u^f zd%c*eGWd(5c?H9Sj#8r}FX;;51#j;;FXnkWHO-IClRNCTYRbh~B&~2N?+sC}{;f(% z^!@%sGAy8jF7`LO!_WG?fZl#khfZDY?0XBwrlr5$b&sr~DZG7yNr_x;uphxC+bYHS zOs$jgepr2rerPkBx^;GQRA(fQQ-9jZU8GOwdC_VdnYm=C_H7 z7U6zc(+9Um%U~8piPQsqQcgjSIwZCJAsp5P_c3kUD`GqCTih}nKI3lgdblm}E;H<- zDBgZdZY{N*ZkYFkz@*WODvb{y(f2BIvdP%iS3H_NR#p&2hnc#qyn0vbx!R?zEu)q< z=cZ46j~X<}B$q$E{pt0v`2HRHKIETVJ@r6Y}A&Z;`C*-={kKbA@>E{QPM}u^u+1tM>knYy97c{uGtje2U>6e)=S`!?yg1#je>4 z*Nm#&2$X<3Oxf`h%-cpM0NtUruj%4?*iotiI0NalgSP7TF(cIFZTidEZ1_><|mk3s`My>nsKMEKp^A-hXWlr|kmzE4-> z<-7e@@D=Zep8td_-o5G%+`G)n^(%^g)H=|(|MKMGU4tw77VoNcpFDA~y!Mjr>X;LZ zML9BH77gP*x*Q=j;|TLlM2wH!e=$DBy}aW~@eI^d@LU~x|9rO(cBrs);4eU5W62H~O?=`!TbzkEQylV|`_N$x+fz>^>oGEMl{@`UzT(sT z8G}z_wOya$%cn=x4LDshABHN*=YKJ{CjP)f+-y;CqbvEwFpU}UoNWEE()hgaV`hnf zyku@q#bC^noVMYZ#|=6yzS-&Q$2s1I;iR8U&o@fS7}Uz5U*T~wG6uXoHUf}UOw=1f z3g1bT<6asR5(WDyhUy^X%5Rwqj>2@8d{$u8-U2C-VD3gI-A>!O=9NCVciC>xh5^Sr zRj|McNG{C0#(I)qpjJdQHBfi~AU``v)}{2yjky7=r+0rT44)&d5Y6V()S(Xt#JJEdHj-FzMG|U#kGONo&+HC(ZYg-UWtKYL=+aWAN{Huw zX%lc~ad0A!yv?PfKZ$g4(wAWeT?(_Y_Z}4g>Gozj8q{%D=T)w&dwg=E53@VLAFzWm zw90sxURt+T;9-{_4!pld3}8y5sfd-io&Xe1SwgjGfdV=lrGKl+_^>Th|38pxG+W4b zpX9V(VGE0BN0w&rE0|FU(~2$B<-7XyU%eVBDFy$SruvGJ>tI+r@(Gcn|O0c{f( zI9yhv#H7CwLsa#@h#@u*Lqj+8|Db%p_*_weV+x3&KXzm5MZ{1!o82qP`Ig?kF?WhC zsvJf~$3Z0lNH`{rh`=D3^0SJR7C81Q>>kc|uX08*oWotZrPrn~nbKn}QEm`AQrLMrfY(tI}q&vhC?V8RLv(pV8($~UNzkRgd z4eoiYu?xsFIIj%|*0+49+(U}X>tR!saWUcuyQbqRaiJ)xdX?YKGp93PUej$gtVH-u z1U5L)n7dp$(o0GM!oR5K8|^4JwwzWlv!ON1M!X;=UtT>#F!tdpD7ZT!Fz#{dL%O{Q zapvLm#?s~zlTuhT^YRiz;Kd3I<_87_0U1V2ph7CS=>{d04{bn|cNrxeNgc}h%mM^5 z4-5$MnQ+Evnx`5xw4WF~41I#!5O+tz8bZsXRhTu;njPwcHfK)9IH2k{s)~`;|G+_n{F6Qb!K!c5DiIA*&IR z=!P!c)4hXb4jF=N(dW43D9;#@yKZWv@`@I79ZBMC?2EmC)t~u{)dOPbi$;sF`dv;I zM!mmSq0SSBwF+X;EVaAt+IqDF*@v~7c1b`4KpnZ3vp=7{!M)~I73WkzhG#VYSc6MT zngq>(I*ODHv12y|ko9g1%A_v8FE<#b-zKt0p&neLri$1-d(|SlW!D}Sq=|^pd zb}E<;S5RW|gK#^a0P)%N#!yP%8p*1&U5Vj))&VzVafApZc}twLKha-$HwK^UBR%XN zRbaekHSLCQ*v)x{ZJgmebn?@{LGdWE?PAL7WU_4E%r7uWt1d9C? zLSwe2kLdx$IQ@qjX8Hl?=;<}o!`_d0{gy&qpEN+bT&aG&5ljS(6e>=5Q$GT;mK|7X zAuzNSu^TT4rb7rmyKM+O0UshEqIoCIl0K^a8cQU1PrXjyAICAn1O2=-< z3-C~}lc?Uo`5QdMQ53^NQ14I|!9#sV7O48sQOS4vW84B&4_SEJ`a=2@bLcm=#aVq- zvigzK*SY!RB@(LMRYcYM0#(lk#Mt{YRWBu?>KlNn_uB2fK-GK1^L<5B{hXT=zCuv~ z9IoFuqjyZW9f!Bjc5?M$28?^TedkXA#`(n9VKUa)j@xqNS!=mTZpOAFMRrQ0fG8tc z*M=Y`oHPEODQyzT=~u>SuuUc+>oRwVG)KOih^b$HNx0dEd2Clk5O7Zq#Y{akFKuN0 zcfsRKRwI(Y9~;vs4|sliIG+F8*!r4USh`1p9S0HP;I8$4Q=w195t*$NmI(El=^m#5 zp-@GYRR4Bp9~-D@r7Kh@`-(Kq)Wbn$h5t5RW9p{{IJM&nWzY|bN-k18nbXvBztN=I zkWpvQ&l?fI=0}lBze%bS!@)pY0D9{5H5NmhdA>Ht6PIf$fZLM=Qww7%EmGa6;VENP z9C;7sQCZ$3!S>y)0+V|_%)?`d0UN2^=)c`Szmuw_4zD~p#*`36`SPOUrVcKYYPTmn zy)izhkI0wN7dd>7&ucfuWOKIyfpLP-Usyv>$kQVU?-x=lfR0Z-y*uC;jFTf}sNJQO zAr(-%o&bt^q2x0xg6cit^i%_Hg*ql0=J{Xv&yX1Z(G%f6luN0zQDXdO{wHcYpSQ;r z1|LgG9>+d8LRMpHAaX8j;bG-avu@vBI>VRvzu`Z5pYfkpcmE6jk+Z1YCA<(tK~upJ z5Y52Vm6$*>T`UlPmfa_KdzI2RJIm|og&85!V~6#zRvZt9XA$+z3vcqe+pzh`TqjSa z`OCYFdwqdx@9yqJb}#c`-n)k8ykrF`k1BXr>-(V%aWD;6obStrCf&h_1_7mdVaqG} zcnK~d`0E1Kd{kkp0m6Q)t}=VAKHJR4v^_WsePjGau`g-KO@z81(+U(8?JOt{H3-ND zFTyo+y_9G`&@WZSqSbogKO5QRkopT^?S+$E2PVqCk;2z?zs(zbf%|T9>0G+&@(PDS z*ChrWN4tvCN0wAHkumbfSVogvM0R>Zz8cIv%E?Y?ei%&6rS~O>0BQ+UWxEOko&W!1 zg{&n>>#GCy!YDhBnK^ab-E4Je?N6_yoH)AeTS81|5B4JQ+HMX!u!2Q@X!k#+d$#0{ zb&8&w*4@*6s6BQ2OhgFdVoi`{aH1;dMP^AMIhmyolNoIe31jS^o-FVn4JH|1nz}$h zTnA!R@$MbXoa?X96_h0AZTV_9<&2xp$rryAQA2F8s@~nUSFl1Ns*A4viQ!u`vM5#^ z!#lxTaKj^&|NA!5wWb%XZkg(lZ`0IP%LHM(eYCBw(BVa|3J%Z?M`e!<*>M4Of}(6I zc~!_|f3&&Mc!6_*X_FMx*;@Z9|0>bnE)Q;pu7i68_whKq!6-=z-x0)VJt^SbmTz@?pLx$0Zi>czw6+eyN!)jk+dN$! z>LYDuWf9MRv5EZ|8hYd`BJeNyJ-iV(Gdnw*k6j>w`Q-@iyXP(~L;@6NYJsfodF_;% zD_jRH@s)z68%R^PI~3UE{!x}ROx22&Wv9KJ9aZNnBcrEzU10yv#vec15BU!%5p#R(IYl$JQJhEMFDE*II3mn8b^;O9oOh z`xH6@IM2FW*j7rwhHpTa+F6-y7wX>`L`Eev_@jIKXz!7V=YUUDgI~@0e0cx5zYA*0 zpMpAm8wl!pC*q8-x$!SSy<8-yzvEVNa@9z$I*TcWt}Sss(z_&3+4zk)X$4ZrlZI-y zXKDmGqq(}r$pWC#EggGl1F`%k)-C5h^Y;xj<}(q0Uq4wW=I=S9{AgK}_!$|>H~_`+ z$ho6KNQL(9g;=KO|06PVt}BT*@i$~B=o_poSU~m!$(Bax+p`P%^ln|9)MsQ!LUO!5 zx6vQJ3v-0nX}*uwsu63v65~XbX|lN{9JhS+Bb$e>>j2sl$r+feSm5tv7x??bFE)bH z<8cmkUbfB{M%t=A_v&b;MtUJVnvfWP;W@b~^@WQ)IP=*Yr}3$^um zfDCQ>J2G^N#+-i_+Df(-Yv|os-r@Zt=DRQpukrCXfArGAQdd(zyIu)qEdGWJz5Ggq z49)$Yks%jAhE!W0ijbjgVq^%vfD8>S$MBvwzd*Biprk?{=lo4Vx2w-|dUHKJ8!tHa zWj?Ynh_oXDAAzqhDezE2@L~yVHhE*1J(xXa7bE}@Krq@>A$XQ9%KJTf^G^vK_zkAN zKFOvIoSDO^)IF(g&%4`JCGg+jbPw|ccZOFRc+hWGT4-a=hlF)vYwFO$4Jcl0O~uF3 z89C}NcrFrFH2ZrftIphWz=4nCA{u>KWX`k2f%o9tv-YI<2l)q}Y;g{(AXdK@Lo?UXaiXkkI!Q zB=nF-Lf4BVG^tINXDgP_;Hq8w(7fO%%Gd~#Rv*7^dq6KDvUQCVX-r?qRcWt{d5jtSKBf z&)G-{_!be|vgHPRapvl*j7UP4gHP_${U@UigMT7oK=x&DXaDu`Y?g`6BtP@Vzuv3CyEjUJp-PXvuD{RFnF1J>xkeqA&}{`T;YISa{gn^-c^{q zrO-x^?R=Waf63-#zRB3WM~wi%$|2$YE&eVnMh3ZII|kqRRX%YD`7~An{+%-YfZ1qC z!Y2A_>{H2;oMPdHjPKaixHNT=V4D-PMR79thLpaUEk8rBxdt{GUj0q7rZ4?z2&0KR z5?guZ9gO!ZL5wYE54DR!F>-7)b-|W;cO#o*G|>qLR!Uz+efk-GWSNfzM5L|fxCspa z>q1zcK$XHySnJo$yGNw-^T2S@TWcHv_PJHdD-ue$S)U;cM_|Cu701625!4%zp`N5WsE>77h7|*xbF%?Y1r>ki=fhzTccbAjA)BWF zey`De+-xyF&yom8`vqpG0{#x3tIxL7XJAX+^rtOV6Ktu?B3o*#gW-zKCfP0dYE4oJ z{0cP;c%56mz9E$^miVOoQuu|K^4>l{M+X4(>i+{kzp_jO(2rTt6ab+A2Q+l8x{`B| zI8*J~LXHB9$EB0R8Pof8{$ZggS1vjl-XS~GF)1l!e!upC;!$uc&?_IGk74i?h88qoK#10U4s0|B?G%qLS zq_Z6$M=J0C@ji{YdxZ{vO)P)&x-{L{_hMh_jM$g@_MxeuU7WN2U6iw?s!8rS5kY?f z_woH3_^tN*DWPF;DO=F$x}%99jzg2poLj|YTQ9oNicWb54?`G9-J742TUBa;ye$M} zrT+OgANqLnO)xZFoQ!&{xsjbR27o)eCOgNQ)URxg?w4qTRt!3wS-v8G-lk(~epXvPzp%3{RLZ9Nm^JLpS^~AEy2f`WRw%hLl0e~aNt(Ga+{f+nqNLkW!~$gRKuNi4J4oVehce_+g{v8HjNcOaQ6oJr{y;0 zC+2^d$CWj3X1wH2>B!jcX;62mK0=kgA{$caS1+ywN?4)G3#^bgy1)wQ{EHQ;`{pw% zq>{_MRr+-sV(F0FnNMe|1)8eci4ODX13hrvHXo?=U&SmShHP%RE7+T#0O^!WxdP+T zHR%kiT>(m4?F?yTcXW-772_T;?)mx0S?P|RxUlCuYel`DuAcm`ZKRYOn~YGd!^2_1 zHgpOZ_xj;=mGL=r>heLso)qGGf;a`gsBbe$QSMMDjlgZ>i~5Zp7EuC0$RiKag)523~WrFVDmF;2xq|zP> zCXGJ7r{Fl$IXvZfzI>2pxQigW z#J#5Vi;AMBns!eJ&K<1IWD$<=d%lTLmbD@a}6baUOorjZ`kp3zDyAuCdCrT4~a8JQpUAP$MdD z$VX&e{+4e-UV>kviJBhiCWi5UJxVv67*R<2HM_O_ob+<%XpdlgFxsK{vAy&DX*1dl z__t;1G5k0=+zFMh)!zuCW256dR{|G_*beG13hh20j{WEL$dz6OL$|5b2z z+V?F7sP-C5|9JxSI(?GMb#RQXG={@TA5%exGcT^4YnLpxmYJ8dCc35!px^52zZP*{{#bB6uQ@TfD{?;#hG=JNE3rE5KhPOI zUMLLl-a6ZK=^oga-^BCRENtcf%;+QNbKl2-T= zC9>8St|`TitNKaG9Wvox$p__{IkVXu+a&DuW6p<1JkKVP-P-`%c2*LUQFV zBar}+RdQUC0PE3Lt9jL&z*jBtkFxRU0>g+8dR`i{P)ICh1PEub^8w$AF7pQ$>R%m9 zZT9_E&XkB=UT&Ztu=}OAOi($IEwdAAQoBn4=e+W1#Qkz&1~^|QbXv$KjE_W_RUA;? zqi(g|#EDhoInnLpyYD^WBA`ID#03mL=+012?`XImw`+Hh-)Zwt!XFdo`A=@s3msqQ ziH?|`7f-ZAUHE0k$?wRtbU?D^u8JxBlp^l;|0|_`{@;|ou87i~64++_{wJkRbMRsk zQ&mvM)UIzGCz>1j9uMSUqjH%bdwNdYC>0B585&J_V<+-QyIJ#ouJsfF=Rc5`9#ovJqZmHB!mwul8 z^>v-!M`Yb_{0D&+NmGuhqtiYAtkFKjQ62|A)i(6rORe|&d%RKYcr&t+qi zPXF)%ych=l*S1YW4@)8A%n3rsC@Q=2Jg02X^y$!p6=x^q6?iLYQmgn(_ zM(~|4+U9^%RCYWFC&X!j4Nd^vE#T<;VcjabdY+4-T_%5tD(T3*hnbBg4ocSp?Vnl+ z$yyQoUnkJ2Md}BX^!BURb{|{A9Cm4e!P*?M^f#GlpILzF-dq7PQ_0!QaSQT0;h?{k z8F)fXn5G`?x62QCcIIQ_XvE`%uORpZIptFI3_OJ09T?Q@OVDd(>3{QrObvF7Go(t{{E~4}>=$FaOhAt26vTV`w`v922O5kg_0=AAVSZUIOVb1vDnFm)0Gl-D|{Lp~NXoOK=g0+p! zKx4OJ7#!_qH^T#*TY17T< zT;}v+RcS1)EyuB|tFz@`!BOF1`(%Y@={ZM=O_C0$d9g#+2W)ee#e1;1nX3)v-Q)ek zof+|?3cQ-^sIB&_Iy>z>>`;4Bc9zc)&5V&3WKRX~fq1KjRC3y?cgNl52APT2(kWsN zu%%rEh7t^OPw8#*uGjCqbjgCSXX#zV8=bUcXiU;W`ZujR)lg$Oc3Ve#`yVzwJB%t% zFemBfTM*~-QxCVJZ=-|*8_fFH+Uz@D7ZX9hb69yMM&r|K<2+5QLbUQ1 z3Go-S;>$1Xh@X!x%kz!cs-Gl$sdxVEzS!?hC4Uz|qV8Al{|4QpU7fkYXW4z(mu^$fAsX81PaqU1g?5IH~W+sjgT>z zJBhF^suf#(s2wIScVVXy<5|ib9jL1DU3E?qP#l#F2(hXS#F-bynRocZ-?PeCL(u7m zFw>Fy*20+6+cn|(Y5y(3iZmYS{Vg4dPa->Pg_C)g%ii_shYgv-5XC^f28SmUW0K)okz9h;a7WKFr9>`V&lMZ4~H)1+&DD=Ok1QAUSq@8DwE4hnvTBI#yU zU^K@~;hgJM;Dy;Q-;n2bDtrp<5}$YW#MJ82{du44UvF8y83p3zdOoF3v|#Q~ zv&YsD*$$4`U=q+4f(86IL`18jo!&Zg8rS0W&@>SECc*eo{d3A}n2v6IlP-$R=7isGRTAV9!4EPOdXI{1@xWF&IhNYV*;XzOk(^$&Sp;|&v~*p z>lO!1N*SZNYIkLV>z)Tb_28DFD&Z)d3v6Qs^ek7=l*3_*;`$x!k{V6#%!Zv9zfl94SF)LE;K>9FP?ujuC8Yp#=;t5rU)b!m+Lc2@d{xL8o+@@_=K zjMq?YxAqXyQE9A~VXk6#5p|9*(!yf6*|qD;&Ya>2kc^vVV{2FX3U7K?6b#{FzU5iR zXKf0)BgT;tstlC5mtuub=oX@d8L%H0g}!asVG#5wV&+|! z`ltE#(LyV+lKAj`X^H>AW-X`s@pX&WVv)R8%X$e>y#55fS|K3g>;^hxHIAoE%V^Bp zuN0?)w{bGHgoyV-gv;sUhRL)IOWn2k$WZn!3aP-J&DG4H+6GXUvIl#Ru42uMpyP`q zw!kr7H=FZ~(=+!{BWbcA=mx~i)KR1ZG@YJNbMJD4db)`P%?gLty(v%U%Esucs;<6C zHPZ=3YqK4dmT6*ZdG&^n1fX7a%%&LnqZ3Zr&(+?ql|+qs4NJ@Y`wj`VI3%4V%;c_5 zY6Smc1LDp$v7@8gS)IV9qZw*m()IpNQDY~W0@d-j$eLERahUTl6}cMoosni<%Jszm1)HO-zoY*vsUzPr=Yp+; zZ*cfy$DNjXG0%?Ys>!j*A&ixJLM|C|j>d(RKI<;>x_ixE@66ArV5m zKut9IR^J#?4NtKsxJK2>P#_yTG+Hw$+HJlcxtfq?D0q?s20K*9l?fuThE--q8aowL z#D$=S9y#w*o2l& zYGVrI)gaEn5xHh7dO3k_l{VH)llDS~npmBK!sP}7W4&ACzDo-89``z6`!t07>qY5R z-j=pX-9;vq0>se^<#83fj~$a*rL&__L++9=RPuv+{^yfcb#hIx#^@Mf@fytEAL7zi zHfds(h*Ty+Ay>bm$o^hZGXk3FTXGk*DWN>L}<8PU_J?h z3|GA#wjR_DxKvBGk(#=bc0!tFqoQ78EeAc#-yBLmxDwIZp*M42S}g{Fj+iaBmYP`B z;tJtTqPfmPIv9Bbm%s~-_an}1k+_v_`)m&NAPbD0UiFffE2HpNTZ!_-6VW>{yJ{U4Q|ja zcO10-&lg^JBoh=MpgQ#g}nv`qgd51r{70`T;MC*WykPtjy3941ppR{Whm zV5yz{Xt>9T9MZBWl43=S)52C9lf@5=d9~iS>V#tBJ&mv?N5 zf(C|gi&g1j$!J?9*M-PF6HmQHM|SOGRcafpF+yu!s1h7#r)(XbLP9eb+Rm|9SuxaW z2;0WT6BZj=f5R%_2=gLZ_$AR_-bq^|D2nuN_zr`P*XWl~xObf3ukI^~Bq5IYE0SCR zkt8@O{~k#aC6T1Fvu8;DU8;mWB>D#hm0N@`lt5-Z`JK8N3zJWR)rn5GZ+MolId53b zY{SiV4&*O&(2Q8w-)T%`Rg&P=Es8zx!GX$pXGKQ8h3g@HRufJ~kYB!b+EgfX>mV!N6plL%y>!~<&xfV~ z*0e0!-dO6ZyUSjX`+(xj)w^Ni)28`Hr#oln4<0JeSuCQfD`5_G3ZXz^qR0H=Ly|{`q4J9?-PMxN0U+2Y9 z9h4$hZ#we4>K5`s`+K`zPp#|yx%-Qt;On%BuE(UOU#z0--L&R_Mfce+bl3d!pXE1o z&)$9f)yyImHL>>6scZULVI3<=NRD6F5rSQgY<`}0SK@px{G0Q=4V>>M*(S;61#+15 zC0wc*GOpoYXKrdJxat9k=RhSogSAYZ6v+zQfS?xV^s#Tnsr2I%*-?e&NzEDD?&t$y z)M%c7&VZK6@8YQr0Y`})j7G3#D(5*{#lsE$3k;&ND>q&<>%#L)ZdAa=21ekSgL+ZpANMiLmDTAjJ!V# z34oC|6YRm-!$iBIIyhmY`gP>IJ*0$}*mt+RQh_d-ClW}yc5%TsbmymjipLO`^9#jMcuBoFDZ`M?i$>$qx2Q_h96 z{=s7-FpIkfcb&~8cDSI96+zo8B?!EAyhZiPZEpO@m+L0}!${dn=z!)lHaf^B1DX9te$Eb6bRZbg+jp8hDMf*Wa4Fi2G$@68YO79KXE)mjTzZf2P|Y4>V; zhZ$>r1Y_sYk5wET@eZ%LN1VU3PFUeYQoF{Vd~y5y?%3sFR34Ie#Y?&dIS-vSI>_2A zeel+X(Gke%3=}9un#D0PbMI}j>migHS+0|nPLt<6mRqR@fgOlN{WdkUP9;tjKl3=d zWGBXeM3ka9`~S0yD^C=+es`XJh<49VknvZY%$OGL@^qn2mhxwvjJ;WPb&ELS8Tvm& zJWC`IPsTR`190rO-rl-97Jlw%&v1wn^~6020#$2T4+^*fq&B!ReQnH zobPXD`7&15dTMJ&#h+DzGQ*b!8TC%uLxYILY){e|99(#A;$ByeYR)@7t4bw967tzf z1+#mG1s*uR)%o<@{oq}_MnAZtil=bi0@`>9cCyTT8LFN&kczt4NfQ=p#J&N*aEslF z*`6h})Ma#S4+|Q@x&_85G!dM5$Qkq0<}d`scmgMF$;tJa^V-04L_te)U~ZXIDuFPg zY9%adl9bW{FP!~xm{7Upze;J^pG#?rowU;*xSx|dWd%>Sf3+?M@gj#SJp@H-%w1ec zTlKk=#{WiAO3TzV9aZqV-oN^H{XcHzY+($Kg+zK zVXy$2bd?m$WuU5V+a2u~@bW~2{8OlSa0=Y`3i2cx|Ft5{xsBqW^j;I1Y#cuPS9vYP zTj=v}vwy9)yrvl?F0bh=l-C}m=S{+HSC1&Ju1x!tyNctMFwm`7MOGNkoc?_$W;ra1 z9&U&8vs85hl536ISXqRQ`gG&Q#H_}z^fAF$BZY)MYdu&1eJLP=wT*NxII#g?FRvha zPlZZ~OtXvZ*PqWdM7q{H%|B{}&j_j|_@{_t9;Rc!hE=H$R{cHZ*&&H}s#e>5idl$x z9DhY#gc>Z1EED4GCc9VsMIPdbZg*4em%L;*bDkcK;(Qkn&H#Ze)wD5 zmT&$m)1F+`}z6p`OO0EmqtU;z+f`Mn-a* zhkg9UytbZ2X9khxH@$mEYCT%mSuyT$2(2#H*wp$~=B2(1^65OoBIyglj7K&Fxpe8o zxL5uE82!9>-^_{irau=)KkLQOkAElR2g8du&zSh5Q=g-spwH3Ik2V2C+n*ZL`V|_W zg|^wbmhX$ky^j8fER_km2=CLYG8#DV+}0%q&~mBe+UHtbm#9|P zazIq8QyA;+b+ePy>I7;y=eMiyr>y5>ByrFoQlbr#7(Le6$Ua)vthys3n-^A#O`m-@d)hX>!*)2Ehfx7xxGF>4!Zon3!&di zf}&JtIR!zHoGV%=4vJFbu$AJVsAr3u)oa>#?(da@t9hR3thyL}1l+&g!lI!&R8qih zX#cTHqL0g$;*TSjRNmH+-i|0-s!!v`H3cmXGR58#0{nM}-5~yL7QwUWy5HtO8G$COXqRaAi~>jX1OOEIK7= zTlMoO7*N^CwVbh$@!Ga2$BMqUZffu|86t^qR^hQ4(yirS`-eKL82}~zmjUuDcw$jQ zHgu`&SH$ZNcJ{7j%KPnzE6-F*WvmpSYaEg`#&~etbc__Q(evB#MXF?tO+a5MxA{jT z@i$33@vh6JPNZ8hHS@j#qp8;FAdA^HsPrEkRE(l5W17W4s&~fbgA-(aVh*MTzD4qw zs@Ak5B6m^9!EZnkZCD`9C*up2%Tk?H)xK$y@;klr6_A`{SEGj z&&!@9Dd+PldQD$cxcMD_Iz@lVTuDDPmE}m%>-zk1=M+CE3{tW zrk6AzOU+?fN>U9!roGH7o-N*h)Af(jOHHeAk((I`Bt-@OB9*+H0FBNPOQFZt#NewIV!TJVck%6x>X$(?KWcaC>@u4(SdE=1`+EP`D`>{|Xcsqg zddOANh~uG>=Z)Q3?XZFV=J;f z$vUz%_KdNl_tEG3d;bCRxR-OEbDis4SGOR_B_u5zs$f`TQ0`EVb{NuXvw*7X*8_k! zC0EeU6#0H8(aPKo^M543rGF$q$0E=mW?4<~=cck* z(w(=@SC@aT;Zm6&mx!WQ{vNQSe%#Z8#X=c2RHw;80BBAeMrDtHXs|~>^xJegv11)L zM%PHZw&B$9fnMfR0h!Nn>lK^5F?G*1OJ_}zhx_xfjGcQR9+XkjSwZ?{N>rkeGbwvYobDdVc4HKYG?zI_yYhf z*qI&Czv#ris#Z&noo~NxhQh-pvY94@+HJLer>OeFXm?&4QSZGP?h~NG z$Il9tfJZbTt#C8wQ3?L}b(GS0w_8A6-lA+Cabuzt7exmK-`Qk>CU>EtQWEMSS`Hk6 zr60F8s>&|hB4|nE@YZA)b zBmi-aBtaC`V)GS5BfK*)*u?<&#kVIKkBa;^boAz0cgAeNm|4m%gz@}{h$Kj+ZhM8n zH$`9VSsx*xiPHRw?;xeM*BAJ_Ddblp5JEG$RXIl;FhkaxDMJ=bNDZ*e4tNn=pa$un z4gIeLmISrH)Ieq3Z%f~Vad!V&U@A4tCrhtVF#q|1yt!q2=iK&t*pSN z^vA}pRv8n9d+KK$txy#Lj>i}iqohX*E3GLHjkR%@%W5SNa5{gGk~FK88nxjN)J%_*9v2xGsUZ*WE!t7O?0d`s_%4|cn{Gkpu(TD)FL zzlwfGmy!lEiC#bQFGls8hXyvsx#2cdg!li)tUP}1+@q-){WU|b6o(i(Rn%;9L-D2U z<@Q=`;o(x^k5!0U;TbZ9oP!9LpMZ@|;#N@HgH%SVS}&?NKn=}Ln2S4fo-VqL_i#+X zVf?)i`?5B~g>U4Mtbh7XR+(}pjPH+B&l1R8dr{@!f0@DwA#>}L4MZ1F!Y|=EuA}#kIIhvsrh6*un4WO_i{!55jdh zu?DHSi7{^9(YfZn%pn)g8kRYPh6!jmKt(Pfa8SFf64IExgnIE`F`v#9E&w2{Ra#n? z0d+T4i?ODPk43$+k93?69-c=mM%PHj(MO8Qh&c7rG1!|<_x9N%>;@J7(>vRWj`)v<%w=Ff z?e-x}pWmcT+wW#yW!wxKOOmMk3@d4SwEzX^p3taF;*as$>l&Z_HPyUHC;`gg;Liz> z8vjxa$TOuY-;dqjJ)-3o6}rC*9eR=AJOJIuBQ^dp&3c8)k4>PGvkJW=c>SLu^V8?P zWOK@zb>?Q=-^CsY(Ci#Od(*-@NRthWQUjvAR(eqW)f3$Cs*#CA$Og@(DHuWmGtHnD z+u*JBBn5>ph=Juzh5Sq2RDHZ4xU{foue>-R;`NWR z#?@THd$mlq*Pqj`d507zX>-agwtEER|{F3Bzcis<|i_i0J(oP>DsyNEN_ZWxU8bq zf1W4DgSJ+)w6V5C<=ra1Y``>@neua}k5v{my-1(5%4!_N>&Uw@2|zh9>Z6}wGM$kN*=|J}{#T%`W_uB%Hh!Gj=J?+~ z*m(Y&dL24ZWwfeQbCUT05*#>(mwSwRat!)Gs6!VC2{*fWRqzWRzo=dVtEoON`GN!cXJ^zEJ z&zbFkVewFiW82NHiK3+$wROiZQ)W~{4?=kXJo`Hui-pE4>bLL!QYlgAa1XL+lRvSVrWT!Ull36&b*o7qlIV^p)06uwg&flbIqX4hmROR)9OB^Li3~61T>iyv#QL2Yivs-+ zyLZ-576RT(tI928#lPitIEoLo>o4e8&h-oR=1|(KsH>wW#=L?GSG;N24Di3F=?XL2 zYxmzdv7@F+tpvpcuxgb-b!X=+!k?ODzAmf=N<(>elL@2p#GV}VH*IXQdk-N-Bk-jz zXT@#*{Miqc65xfJJpsLoANLMaIsIG`93>b=4j}xS^L&cfbV!YsAu3xgV3;J-JK%xg z&glKUu({pSH%ZH?0nFM*k;2e{40ucxvk|4%8$i@iK0B(cYdPTu#c<6ZlE;%cgWm&N zG2Rq|y`c(diY1gD3sFb^6|SGFfTg?txttt+d>Wm-riGO2wVulkSbg!ZmPp&MZGnzQ zO-H@?{=f?W99r&G$?eNhJCK=*V}C!lusAN38?K(USHX(zRj`_VeLhi2TG;VI@b_^0 z>hI!)a=KJe&$Uh@S_}Ayjy4et<%6{iBNea-e_PYcnkIe}!xl?3i}; z9`oexv~U&h5cJ$9M~t>982H#wBq^t^V~u-QBYDlUvZ` zYuoDu7S2nm9|OHd&6m6eE&yb54b-G@?ImkCid$(hdU_!7fIVT;H%_T|UFrZju?V_d zqkc<*cJE^8n6NQtu9QoSu+lb2BL`xK+Ci3CE%wJj=ss0f|A55MxuB?ePclgh+aKPu z47CSGi*I+P@|JY*8S}(2Bi+0?P{3f{UVD%mY7Y*m|zHTZ*7EC2a{O=Somlu_; zVowp3M=PfS6|Z8;nwHMqV5DpBEoZg3Fm8>pFnkn;o8Qx_oUKI2d#{E9@ zUtWBT1^><#x*=m?nq7Ur|D@{7d$pOwF>46vDoi{#9|ca$-)dBcDp?muaT!ah=lg}& zjw#;$6A{6hVqR{qd>)dV8_LTvL;BbyX)}`dD%oS!<@yQUq{Kcz1$wDFR6z|$W%>?e zgfnsX>(3)~BezW$EOGQ-9Z`0)Iw&=NT6ZSRl2YVHY6;v>{dax33kPoQ4^#u7HnR58 zgopzqBs-VY>J>A%{2SL=>c*F$@~tTNePSabM%FEij-(Ij&d zXoqdar)E5hsTq3pr|FXajwWtqgReiKWu+6qizXU$%5g@B@$m_}$lsU`i92DNhIT5# zI(KLdaxQ1d?Kjl>J{hkQaaHKGt|-Te-WrNZeUc8r>h1o6pU%czma8`4_^$amN#je@ zM)eZ!jM0xyjEU#;=JA$qz;sM!LZ?rvf7+Kg4*X4BZ7&Wd|4Zyn{+Q>@dOQx0iXVH=IKpO9j@1)Ss0%lpec)^4kED&p}D$90F4O%31Mv#Lf@D7ahg7+ytDdVl3@A^AdBiO@q5Z5uK=Au-jxu@icAKa~(TyHaazqyK$Qj>0QhdYPJGOEHPebLj+`c`O)w}^3s zSUb^iX=OwoE7|n$EhB)O{8e_CoLI)5)WXjj~5GS0yfo)q*ft+%#QvY!`C*+*a z^x|J2x$CNLSC8&GfPsDA^Z7=SD{pE0YTIOGDK{q4|;Ko0V=;rSBAJ}Lt8!0rXxsUvn0FXHlB^aw;R53!_Kgg6AL#JS!(bCS`X9*_CA`UXoJ z)ItZ5h>sgj>i+%c(XI=tJZKk+oV?A{lmVIQSjob^bL3-divbd9;X>-ry~%Ff@mH`P zh%eRUj$(m%^w8YSxSmqJ)nF^)llNQUEnuC z3Q&eI`>bv3g1%UOzAx7;V(g@wN=85*@{-719tjLmNLfw{!46%Dp%8wV`|>h^l!&Qe z=D7CPJ8+68eRonNFC@^#q%!6fXTK(61Ra7tygb5-k4PjhtfAX=DBuI7Cbn;grsac1 zSPPMz@4{qO2W1Tn`8L5f?RqZ$^b<%_>e&_}tWHE!2&nL`uUGZc+%VgkRG@3JrjIvu zyOYSjzJvVR`|hLnixBRgYLchJbkgHzDY2x{FkL>^r4_cpKc>87fv3!_C)u$_C2x?8 z6X^HUWe+4^rT`ydn_x(aeu$~>!STf&LboVf)EC`)zlU_%vnp81dV*h|+g!xS7W}Lx zluwodNcdxvO(v%eQW;ftr)FLmB4e8r_;GoV8nn@TK|bM{Kj+4e?)=cW*!DLACHL9a z=w6Um#F&R?5uMaiuY2~{ zOf?Fqfc#gD?NZ{`J1>M6WyktGS(uK$!B!pQnp!8A7QeoWzZn`g<|F5Qqv>HSDDa#7B~2+KY>HwHSy**l7S-2hxU0h2{6Rg{1TQ6q_D z|`#^!;Gi;_#T$2ONPa-KuK+>Z#nK6&ro$qIc=qIYfo zozdmnXJ;7+#9iKb`7z%M=GFnYRZu6me{TDo6%=g{HeEUY;a117TbhlS-k^O2LBG%tywoA$Y<9( zE?WxOdlQF(&Y1D0KK5ACL)#mP*AwZFpDwF6CSx>Z4+vJ(6|v1CY_uzwvnnV)6QttK z2TP5VdnQPZogsU1?RKZ3dn#XDGO=KyY_thvcVVbBi58OWjgZBqrBMgGNO(-fLV zavkj&Ceg>NO*@ie`J8hdB5nt*z0yZs*;yPesthEzN=iYV4d_nMZ6RaIcDE9w#YE`= zv?Z#lSXU9LC)}Tv>c2h?|0dsN!qE_Q8e|RTHV9tO5aX-(sN0AZib?5V(PVKbH~VSt z^MKQyQecEkq}xNf;PoCH%3uaW)W3cxnLeCK+RZlmTH>nr9@d?IqkQG_ww_H01vc>6 z9@eo_OMiDs`oAwdi~s|lQnCLQ)VKD6k}4ZJf z{uo45N)ROBZ^I!$LW_W7@raVhwf8MARejfoeBbt2KKbzWroi4al6sv-MD?MZWI!Rp#n22T9=Uq^2(+N(a0^2Ngv=19R z&NPwdTY|P;c&$8RFv_>}`U|Vakf^XU7MB?yNJc`FqhkDGQ6F7!xSovlm6u@|BjoNOzQDjW8a>6_DvRY)i*d#FXowXr~2Y(B#?Vx z31hjx&2*e8db+w{WDQI^H1|LgZU^l5!o+9MByI2$_bCT*v&ou-`ulAssSxy#qspTR zk#34_G^+>6>+VWVH?TBw$9taxO>{dajf%>rAu>3GcEkiRUo{IQ*W0Oi4_C(Rm9)wZ^1TCuv_O;D?3^(@Cs;ftg?h?_vc2Uc#xD+TzhB|thC4$a#K8k(`>qv} zG_VaXiNQxGf1JxUVjoH80vr5rC6K!vT4f^|4V4HSvrhOlh39-TwW@Kiuly9AFmSbrE!-hzs)-npWEOOEv?R{IIvpdhY(00@&ves?@9s zeONDgr=`^$!Su+xNJBr0PD$_5%Or|&CcdyaLS8tid;W#pjcm%Fn%3#p)+e}8*jr?} zMJmuZZEq(pdtVV$*QC}S80GU~n;xKD)p!Pt;Q61Q>hD#ejPV!${YV{quM=>u!v*YE zl@{Au-vy7uBH+z>)y?mYxV|z{%SF3)`p^rfB6qDQ$B6L>dN`LSqFY+m2+NJldQ*^I z1{7eFlQG1JIOUUdFIMxSSev+=m#duQzoy);B-r|Qew^~M&OCYrJlIJ^9l}N3%yU-- zLxae4feRpP?W)b=+SBRIom{9`8wjZDy2tG;c3+{iDYFZ@+0|XYKV5qrdaiW2r&<@~a z`Ca=p<;9Zb5HrHEicWH<(~VKC{R(o?U4YUYm+;Pw2uTF zoipk!*0JHl+4k0F&jtpB6~W7}Vl~i0O#QeC!#ESjbH3wQef6l%%J?LYVDqK(tAqDH zonHT1O;`@rsj@u$q^{zk_!!Ien|`%Ye}r+gXNvvuF;JBrZzpv`H`h7yncbm>22|ae zG9f%#ct+O=(jW1t5S4vS#yFY7pBrJNk*LK+BW$PwXw8hDxPon8N`HF|`{WF(`lAtM z{N#P_m%xK3(GdysG9Nzpy_Mp@G=MfqbB5|Ny<56#o9mYRV@x4>LrR3W&(4=W+wDQp zX)W7|C!h$_ydu}8WLD~W;UKKUIwO5H_{7v-hb4NzH~n&X_s={ZBHS=6-4}nUit2cx zxb%A5Cswb{l?(k48y-0jysDQUgP0pIewiOkTriXQj-Zq>MiKh2-j%wO^_|b6O?LOG z_;jl+4Sw$Ak(V!O_C=Z_6-E`*FD)gE`3k0L*giK!!J-QS9tz2OKGRLK{N5MHszVtO z#;;#}N7c-KWyHuxZE2NuiDs4E&Q%6gsofn0$ZnCh@XXU?{A$S9~GGb9`cguOAWFa3HM|FTi| zt4F#i-z{;e?Dn}X8r!~a%l;yp>E=+?xsG*(4j9IuJCo6kswnm*&G z?tJ5_li$)x(B}P_7fiK{Qu|$x5P$-O_>c4LK$Rzjs9Q-0U+#EHHOan7%XmQ{wMP>6 z)e%(Ar>~*YIgSx8n_$n&>VlC}leg<5!aCH8K}lWy0NjxxLLZoS*4Pt%U*qBakRT$AiM`<$nTyteiV<3oUd zpk1P#Y6Ub`+o&xDjOpn`S*R){*L?W!DzsTo9wZQjVpzr0WjmZ6ttg0153xAnzGFaL z&WO(OvsM!b)wWd5QkG2Oc`cf~ySMQKqA#~F0b?KSCJ$mL;^I=*mtu}`R>Y@mP8QQ| zHg1kz0>qNeEaD#3HQy>*zo<;2lU+V;*&Q5IW!a}oC_by2wbHq1RkPy*7sf9qY;*pt z)>7EptnE_?bZM1`qd&g?;DW4}nEPcFANJ+SX;-vO-baMYA##^J2Kt#2-_X@sJIi*& z%2tW>V%n%kIEC}^v2bdOET4!?FcNNQK{=djZaIf2U*Z+R%_zvBOs)p#+2d9a{`ro# zagXw~*;XFZa_2w#eVm)tPNx)D4(m|fFRmEaeP&s?d@zb4Q1l~HZh7mH=y1<7j>*CN zn+&Fs3K#hg!KEKQny$!aS=n|mlgMSk4*PUUP(97g%8K*jd~W!WdmJ+`&wly#7P_li z8{^mtr?)akip*(kYasGI#q0yKLLz~F7K!TqVn(wszSegC`Pd5>xu%g!4+~)bZI-^6 z#QA;vL>uM0LudLA+D~hXC#QK&&r(3%+W{r=ujqnL4?ZOg?pCz1>)+9vkQ|BJt!A&| z)=WCl0sKm)yVttaKdr^K(Lw6o!q9>)_q+DDnb2h0n_ZvGz^ zKxC>e<0Tb~?@FhN*&ec|JcCg)9cUush&AX#gCnAT{X*Cra_t|fU{M0eJ(qii1glw& z9xul932*Y-b~}V*+ZSIwb*)~w9C14@@&t9UKut5pqHgmc>eM}omCMqKzv!^QSNxLxJwf;qLiN$!wtuhyTowS7oAUXMJ&`4mx~h} zXkf7x+f!e)xHG9YEmbNpvbP4eO!L5;VK2ns2?IG`#NO_+{+|yTP4}UR&7~_8)3LN8 z!AyaEHF?QPjJf)JSv*HyC`T(fdUGI^j%EEv^va4*oa0rD7O)tt{2}HrJyVI zZ<=Rs_+h{eLaDyaX|RpB@BC@guR=+$Fh($8F{{B`yJiDMmTR<(OTSMhb=rWzEY*CX zTy|8hfy4Ryuf7{Z^sCtB!JsqGQ~5vQ=@c6&t5`^CpFMzv+IhxdjmMnaemt+`vgCk81 zt8)W90DH@#Nyq7ksb85xye}1LLVM0q&@@`_)%_h#1B1_~qc$;&lRjE6Yhb{Z3TbuIMuYV0kCtb*bmxhaFPqT3D|nA`hB?4I*EAK`2Nb!)N6 z#(qunD^Ryt9!P+XebrI|HsgZ)$ZPLMuiWQu5z3*x|2KUViJSt|4`Tw3v@b2O zQ=ZZgE%vavhSEE3yFYRH4Ry49s>(wcw5z6}c4sQ+ak~-9UBaezOO-9tqhyRyQxR&G zSgh+vffKz*>ORV3nO?L|eUXp`(u5)@t`0@K`%c z*mHM&c`&jWy5gORrzq&6O2Tpn79x6x?Ga$qt@BW$*Wo0DIPCY(Px5ij`{#!bY2a{2 z8gI?yUv8Fd%R(Z2QR!TX=HmQU5o?ud<5@NBQ+0)&obO zM@)`!qAm!E&7LdQ2XcctvXU__85~eeL?PeQC0p6Vs)6@oSR17HI+uymWK68w#vx`3TliVFfq;{R_%Gt;wngIUn$NX% z_^KUFzov>ov7ib4l-iO^Kc7Wp5mS$ffOb<=P(9_25qmUiO4Mwzq-4?~4ot>M2m}l2 zW$5m~g6N`)7*v*dWgA~5#>wAW(XZb*I-nriBE^GL9HvjAtxEJwgj$~O=zElq6fljx z^){#fo91U({t!@;5&pnhpAkB`PxBy;@5GwI(Aim0JBz|vaq3h+!SoQl?ts?LU6)6l z*Rc9x+t2C&Gn2AZpaW`vP{PGRIq+@7YbK0jCx0C&^{Zk@m@>G`=>(DjxX+>WX085@ zyOjM@tWnu#H1Ir4+D2yzsOoSMvYFyYq8BF*hss>vu)q;++~#(ze$ZCAEl}BN8FInn z6A80G$xbb5q^Ct*IL2UIs~@zC+E=&sjP&POW&ir9y$Y1KrQPY;G*@S3Ss9+u$bnKx z8*^P~86lZI=xQ1wB&_5?wS+y0FqkN`#PJTrH}}DTkj54(!w@{yk(zSm8uly+^BJvJ zX(7yr8aezIVM7hRt>=UQW7$Prw@%zp5T{=0$2{WhJZ1K&oe) zF{ruE`w!4qzGhU&NnQmvt|l z+!YTpp3r+tRsl7&g2xV$$OVr$mWPt~`CrLK%S=vYxcK*}f$N}&~PI`gg{99u{%`%KAJShZYx8uz5961^x)M6UZjk9HS zBHfsCdqhZEyCLv`BJNH(BbzP`S~^A76ifF)CaV!*{BP(#6qgZ^Cu|Sh5xo);<-r4d zxe*NK0F}68POl8(!aE5Ae7v#;t}&9smk&y)YsW@?`j|@kXG;?Q5#My&M$cBi%oB{QaZW)a`v%ekOyj$aM7O=!L`PKQ4;Cj(&p2 znX#>Jxgsf7NusOh6JxFht{0*bh4IGIV`A=|+pN}^!>OhtR(mxAAL9zVu z9Z?>hx!=S1d}fTGXp$2tdS*U@W74N>qa-W|13(MrD`8v)6&@xWc!W)`7--N=HF@9_ z40mAOJHr~=qoYI1V3SF|$01^S=$ZQ)^dy0p2|(_sqWnj|5c<~E;^K7HZQTqQ9QwA2 zdjc87RvBR}*i+k)9jJ*iw%2Z3^m#L%fE|UVw#1y&ZDH`+0;P)%iN?%AAD8{N#D{@$ z%e3NYMYi|{awD)OfrT*oC+XDIf~&f_vIdx;7_41-4w%Fw4g0`CSZd* zTy|lVw^)mxJ1={zhS@zxEzK5unjLJq=7#ruayofjANihsRWBE#xt%Q_gde^p!fCsa zGlP(qKVT}`aP%$y!BDhq7CJdE>PXYW(7`u1PJ@c%1s}@HG$023(ta2J;5~AekdGQlWX|KjRZ};PHWDfpF}6S+?s*q z(nlh3*QMN!WaTDO0>|9d9?Hm5&dd>V*(+q`G;!@)#XPqHc6@}I*xdI&l2E@64G6<+ z0PKK^zco4-E{j5lC#@nJX&KW1|H-<1N2d2DOL!GQruSVX1Xzy9%pyD?*QZDNE$A$c z7!5g9aPZy|$)*2-IBsAGC~4cx^7TCXAZ&|xqHi$pX>Xwwu3vrU{#vHGY0$CQeFDDJ zB6d?xGgoe^n8ARD6s}eFrOQErT{BbM4Vj1geOQ+fq=4tY7xhGFzy6GcD9-d9TJZ0e z?d{>a1iRQO$!KoyNP{V44sU11qS-|iL?&a>i7cdo4Z*dBHts{3A2O#=-Cl8LwKC#}Re!6zEJ7MSGZPvLGk{?ArYV3w0b&&R|MEq3pI+~J z95WPH4O8hveb*HptC=)d=k(7mHcgQ`=-a!hqJ@C;B02=#QJ~2Deo!Z1wWm{iKmP4A zj?YYi7wub#-&6u^rDAt8bdcw-9MRelidzj1q(WHDx>cYu4dBsk|4OGpI}(s)yf zLi;qfCVRHm-<0WzTzv?RMcA^`}Tbp{U~ch)F4!kOKn$j zfwO?Ap*a5}BJ~twY7b&0(Ub2IU&W1_iP?h~gO@pL=EngdB($&xG4`TAw+Y%>pPw8R zc|?Jo6vw^paJrvx%dqc@$(8$o{%)y2w@k(>SjUs^9bzR*G*1~x)BBu)5Hl}Ss!GW= zn$q~gRABOfE=Bv7pvWQJyPK7pVbl8f^TQ;J-JPcXy&In^uvX|SO$rz+XA&$J2ZVQW zR-kKvzH>+s6#2?j7307kf(a)Wr$kl!mS6}9Ak;HPFigtX0u(kQE~OR8_X?zhI|43{ z`EJc0NJJrlj<|rjZ#$JGOo@vp|0!6r4?j2m{QJfVEN^2zHWmg}(8br^-H|M@>*nki zD_KKW8@7}Cbix4X1d!KnuC003K)vN>MnW1yhf!t?55B+CH<}TmxRQ+))WqhaTnO+% z*LOk`VX{mL)KIK_qY5@pPELOCr1Z3jN#of0;QO78-IQZsR7Y=X9pN2C6Ok%IUl4dY zBN3hQ1$L;RC`zXc0z?`qUveQyuCe53(yv;O5=m;g^_DsCO|439UUFjOrD_kFcH4(;GBSmXDwH^j|CA1 z2NZyA|7)pGKQj@1B5&;Dp4>tGs(#Z>N)k>x0UcH=p%CZhAMC z{qA0P^fH#)qX5OnUWa@N$0oPo$H##TasaJm9-6qSrwa1$#J_0PV~1!s_1BODdiD8? z`sUW;Ik|U$xa@ z*QUEd_*Hbopa{hdn^SS=*V`wnsCsKwyOJQ~PiI^}U9BXXm<;`E)u-Y3K1yIiu)baP zHy1lO!bbVX9=xfiCr-SXS2&9(k7Fg(tiFOl=EeXwoNrif;qebyb<@%czXD3(%-s!f zRNtk0$bTZ__aeSUO8oG}F@6%n?7y;0XgSqB)fy8zve=id68zPsUf~+DNAI{J3{(Y^! z+&o3Tlf);1@|LU?NJ`N=lEA(_?31^Pn|jYWGgKZ7_`p<^Py)?{m_uWx5mChENzv0g z)7A;}GgwO9{FH4ZYlMyFhS@gWX~>IFfU?Ef0w?4IUHWtTzW>b7MvY!Fc2gV+=EV3u znM}x-C1JE3?C!ZLevptdhyI6)b#npff4;Uqk=jHMfSYd9NAJFxja`3K7kBmVM;$C} z@02erM;c5btR7S1!khKJ4&BsMnv=^|fkHGY@`@M~-%2-z4q71FF&uxt?$dm5H~6=Y zg#)EVb3}q7Dfc`c(?$$urS5UNem73i z_{S}ldyxwrIG6Y{Z^g?VTU%UAvUk1|CFnMo{#lAG-mjVbwe&Rg^^I@eaFzp!m>XQN zQcYhn6VlK}m?4LC#}hRl{ncL7xI?11loDLmd!90VXR5Pzh_64i;U!@3!wpgGP6XKl z5m6bnEv>dtF(M7=qF%X6S?kM9J!gWAUi__%P3Q+%$W}pUhRn*N%_2-yS#@lt{N4 zX)ZFI2eLE(#R7Rze3O=ry%wtoD~Pv7qdj->?=wLnQOCt0aFyMri46ATo=S>3KAyc9 zu!u^`pUt2;KCb+a+j!lBLOV6{f;76%6%tgOM#{2CNEtAzXQTcU!%K%nik~B`x)q*Ql ztm%uM(gtePwp@&I@Suo#{n5vUwHNXaRfxz;-U;Y~nxm7Mofg<$1P<*;c@RgXmZ^fb zbd{nI!)BnF4ve=!k*||D<>5IJ-^T4l%Fi`<*f&I3;`oN(O=xSWW(h{G9#(|3=p(!! z;g4T;)_=_cW6Nq_xGrPEbtnTnd zuT*oyk-Ht$ufCA`sLVIbC5s*l^H~XrF!Vc?45g@V2GaX9MdUpP_*zU+nKl!<*&;Gp zc~Se>H7m~NtwbB7Bes{=;Ds!eZ5xWRlaDDJ6M81thf6wgy=cPtQ7;Y(lrf1Wbx6sn zCmmZZR#J?WnM4FHH`eNP0x*JXugVlFly+WoAhxtl^dMgMbaq}A!(F+6!b553#*>xe zb4pRsi1mt`fNT!;WH2x4%{|%X_7|DHzQ4XM1l@E!u1e`z3NlH4g<99Sy?E(T)SW1R zsQk@*1aP(Gik&PB_D$}#I=~Ep9(d!wZB)hFm!YKyu)CCCzkdD7&b&^^&#{l9XvRpD zv^2Lkzal6E8GRf3lJ*ikaWOP?J5%WRHBdaVYpzt2-Mdit}w zaV=+Ezs$?4$kA3bQ$niQ=GdC*!tlPjFSsuv0Y_{PS$`c&o=JR3D7$^l{lT4J3%I7b zFg|AT!DoF)?DXv$;*U=mO5k(Z1BdF;`-Wx#PH|lLBqAsvq*l-k*sgNYu@z+>54DSa zXH=GNzJX`=8h+Zw5Z#MC5pPH`e*K3V9`;TH9Y%~l+Zz@Y7B%n4Lw+(xlo`^(#c6s~ z;pG4KfCgWwMwh=^-qKG%A(#au-p$FLH&@PQ_xo! z&_(;*Af@(qxe(C1@ce*C_Kdf9%a@O>mHWpz^hEhp5wfH|RW(~VLih`16f*38=x+O- zPDBsEs7#!Kdmruyl`5W;kXY6rbLF<>4g-_2NunwO)Ov7&0M9}bCXU9_pX)Fqpy=7| zl$BlImYTgs`%GR_L5<#SFhZ)JbWvta=IOSYuE$$W*#(@pYY3=|JIe7)^2nn@$qV+} zW+{(Dc>6C*L8XA!l7YawhUQqf3D2jOVdY=bbNWBx)+epngew!iURw z_~9}MGwZJtVVntxyWQnCzI!V;6N_{$?Ri6n*8bj_hkg%Q?3#l%jrO4@&%}f=#^=(n zCVSeU_9jisUhfSfBp+o9efxsviE9lIfFP^sW&;5TEiW!TIdt5&%%DA@C?@I6l$6Vn zoin`3pU94{_jAm{IgTBRk2S~?i`HmMzAkfw418ZI z`En2kDc|B)rB5G)Z!pze1IL!E+$&mXLJPjjE8lqxtvO zxOCw(V!EQ$MBZz<+rjaTqc7=lRA``op4YO(F+j!Tb^N&xTloleo&YZPy8f%YjZvuu z{idWpmsg;JY2{8)6W`{R%Sg}-hHs$>WQ`_1cu#DKILTO1y;CNde>@%|@4$AvR{#16 z5Ol6egpyzr%(152jxp)=Jmd0wI zUgH^LC^Mvja(j`40YeTbIly|NS+S&JP&d*+83u~sbdgXd#iGabt<-<+Ja{%XNBkY5 zSqE-2({zPP;Lovi-%6su4v#A($1M40&Cim%xb?@$y2V?CZw?LX3kF>B?!RO3GF|aG z^ZjeOM#`2k?>7;Jr!e1Um$KNXj(+hjf9|v!rASCT2}+WC$gH1Q?}QP`aC*o`Vs>6ODgPbL8Nx ze@W0IYyYtS6{z4?de}O&DH9)2d|?m(9e5XWoS&E+J*UAMg<#UTR~_?P~N2M z1Mz7~tuO7!S9I1@9)GTgsAzJ;Nlm)B+$n`UQfz!ncW6wHe2B*79{oa!noZ`d%yC+q zZxivU4Zvs4I6Og}_97(>={C|4lob!`z%Ux|&@S?7pTT{fdX|qm@EMyzQT7{svEYs} zOr5DP0B~TKKR>^oQ(ip&=WNM)sl5G_pK5=Y03E1+aB3Ro8MMQp6|Vld@D-h2!bnM$0>*@+4{KFArFqQ8bVKrZ7aG_9(F2J zvTOhuZAZ))zg3y&i&9Sx{XjI{WZnu!W1q3(r`iOCO7&&WZ^ZN8Ex2%2_COY0ICA-* z8RL*S$|Ue{y&YBz!`Zls_i?{dX7IQZ)gADc69L>LD6YXD7y9NAXNCdJ6xH{`|T4HOp%Oi=Cm9%6g@@TJ(Lg zbv~i5%>{nY+L=10-(Rw`MWH%6DEr)6=Aa|S?iXwse+S}t#Q7m7ox3CrHG_IRebjLi zBqvyHp%F)P$+)fxUSdi_M@f_ z#h2L|T#i!1!LP9kI(#%?Q^u#59F+GNq0}(mns?UOmq%#34Gngk94Y%04p`S{Z#QZ; z`fE#F@i|Vg$PQk#VEyp=d8t(>8 zh}$@T%4=SRPaj_N|5o>NU*SE7r}uEA`hDa7 zMIqUV5GTjXo~P_&?`)ajkZ};Q;wVw*;NaLZTSjthLS&r78`(})_UPC-j{SZ0`F+2? zzu=tvb+7BbuIu@DI#SSPT0`SjX&1;aVho}ovdqgNeuB+H-|#EorkZV6@->OLUZa>(yQ%T^^o_#XQqq-M9zde0UFGb$ zfhu!A!MI)A=}@$1QiLsTGPSeAOjYTMyDnmZDgn zocH&43|%L}ZJv8EBsJZ99QHeFw&BpRAF-ua!5rG=+Nkwn{!i~qvZeKBVQ?ngEUL;I z?LGTD7x+u|K(&cdX_Sv05B`b~MXxO1ADfo#5oL78FwfwJ47>7xl>G?tk+i~-T>OX+ zju;dU0U9_RC_9Eaj3G*wnqFv7aBH-Ln3aLfIBWfIcz4+8D{Z8b+#cflN3=!UoZXS* z){4Hzd^ShIuWC=_GZP!stR%cknbl=FQ`zhYbr98*|MJ&ruVkV`>*_|)`xyL*Mu4FF z!Gmd%E9lGbMK+}k#XCm$)TIq&VD0%R zGcIzk^#>bh*IuKA(hH3hm3;q-0r>Cw*Oe#BhxWitSkb2kKzje=Ua136#3mIl5I;Yd zO%d@$u4`&&e%@zjgS1vX%1%GtR|6%$vFclXqD~KrKZz6|8I~ht9}LyEHnv z1<)2OXZ`-kyP+muc+7=1ycqnDt35=rRQkU&gJO~SL_)%`duZRoBZUSpV`0~>ptZw% zQv)=m$~mbpAf}#pVOfZ<>F|zrhhyt=WsvlxvtYKt?jb)M=b#gTp z8n%{Ov7%iO$U^Qy06=;}1=o0&|BP*g^Rv0FFS`0q7EBD?qu^&;a9LvUFF>fV=)fG7 zN32seN~;Nqf|k>-%GQzTB7&mk()B?{#imn13#6kbf8ZYJK_K z_%E#1SuCt_U;&0&<&-f3?f#p!hm{X>f$P^A8fXfcEMG_jXN{<@eHWE1BnLn~eeM$@ zVv-8d3Z}yJUDe{2_uqm1%cB~~rQX@+hNJxie^d+1xPCQ*lWv2*9qdn5)zmP?z&;_$ zvSB@_Z2%^r*r^?J`odt_PxkfSn$g>`R0dOPS)$Sb5WJbPqN4gvX68n7CMgrHCR4DXN@J$&pD$YU%D zK81ieh4~s67(B<*G%{v&4GH~9Q){*!`%^9-7uUJNf7$Ef=pwZY|TSvf>H@2G)QHV{#4sCJe!SLId|| zTozs^6a_pM;%Rp<4g!TnBDjcHktePAaPf|4SSU{<=#N>HKI9O10a=fibK72L#yJ&_ zQm=7_qu|y!sQt0CbhwXk!46d{2MZkU5Mk5%dqzrg8Geo5oy}+3L$p4N=D=O^{OYUk zr%>+^^jfAkGxysd){9VQmf(pkUb_JD-Udz-hwE{&=mq%BY;)7-^pLA% z*r~+oBeG~V97PJpL|IswOgZaFpLxl1Ddo}&CmT3(QmF|`E=9-#4N%~#_qwfv7V!D? zDM!N$=?<+tuCP17IiwV{yDZEBo_7^RLhttPpTVR=%}@LvWaS~|c)SuH|Mig{#qB-| z+l+?oK@atTd`ZW@XjQ6sG<_Pn49+tDSLdPB@^CNY>d;aW+Xkx4X|y|671j!A|-w77jSx*QAM(t#!x1snzo4 zi7d1IK&S3ZnTF!4owo4+6OF$$+3q+ zz#ScvVt{X9|Eaf6w;P*#1}NhXZ}%nk__z=~zmt>oK7&m6_Ax&RcsmAag1R8?JGBxw5#%?ER4eK{u`8uooWRQ>)Tzmr@%IEvQ@1gfv!1NknIdcGr#e$hc?x zo5~dYX`vKK578;an_3OZJ4Wh=uMWQ7Fx4x5tos&M#M+R7Ybd{6`=15su1EW7s!K0y z!yZ6z3l9_k!9#9FFo8M~$OUk+v1I}xeOI8N1h5gy#M^OdL;&_*u=u#=4d}r&H|Y}_ zmLb3#>^cPw9?N{K6(i20WH5g((1$_C>^L0;=pw{%4cJQy_|#jrtnO)zD)>ihv`mbO zZWsq0h^$p31do3>=~-`;hop&N$H~AYy52aoNi~cLcDCElQJr5eYhb$O(zxDn;;HMr z$+hXM@s9Za4J;^8Zy|-E%cBn8`tp&*2)G5TT;I1#*{67?Wi^3sonJ^OL{72c&5?x7 zzk#_KqCpA&{=fC{?m~yW)Q@zSG0^qys~>IFam_bzaq&{64$%~jR5V`VZbX#vgb|U? zW}R{mrhRr9G;kO2i!yAlFk7eSlPqLm;q6+cf2L!cO3az~_v;*hJ5Rq_v|Jaarg6Y!{lv6NY9Tn zJ`qMsxB`Mf)LA z^~-8Z&;<2rCBB^QZ@&b0OGcc2yzCZC?1;3Ie&MCIvM(GIGb5g^<33=%;ZP&o&DG=Y z#1&_3ojv1nH^^*=k~Nqku~d`DRHD;ka3ObV^FZtlw-{x%=t&j z>@~ho7-NAki!h}R!sZQv9! zTPOu#^PSmSR?O;$*GDz#L!SPMp%Fxqsm1s$w|3GzWC0T)?UL*(1_2e=xBi;|do#Oi z0G+AeJ}@Cymb=(tfMa5!szG&biQl)J>bebEzD>{8^%@tV@DzuKc1z2)sl*L^w8Va* zKDteu4UKr@2`MiBvG&h!?Hs>g7`}q`zH&i zL(rqPd&=LuN8wrBk_H_oSb|V<;VAsnc16v@(K}8;03w#LB>$wLSE!1&>VA@3m~M~z zyR>{-6z|zy(`h55ujHE~jahR26@9w@x9lp~&*(s_$`7~B&z}dFVVmn4PA!qmOkM1w zOd_1t3=WkOzkinAH>SgBpA*{r9&5M16zG0%q~s=`VcLmoifY zUu-^7)h9e0&Jz=F#VE!jv`{cpfDaP#{COzJHW<1`*7}bRV#~9T6ZismVuQIS-}f2z z0g=!}cgUHD-QjP={EjvOEP));1{p!s$RWqOgWEITp$q=ELAw%$1k{jiD35UwNB*;% zO85)6XJ?`YqQFA+zdgtjjHr^r7J2ud$PJbgqCvSOgfDpasbSr`v*MZoWHGDv;n)v+S%!dAfpsZ> zp%KfuQb(z-#0ZcE?3BbTY;9pl`nC|N^WSfxq1>WfOkqxCd+c}f7#Ts=STdgLL2&Av zzj`#exwhiio}C%H*SztW$FIvLj#te4^Xuc&`v3f~{&T7bdDc#O-)Ye@9)VDM;WH%;!$%R2pcc zaRvw)fWY?fWx}BEL`Ii)*DiKVk1$qh$Sfyie&N^bDHBki?v99yf|pVmjaS4^E-MFx zmt@t|`e@JjZ&qSX@y4nY!(Kf&PoXiBVrHQtIt0vv=)G6ob7?g#X-)d?_S23W&KgaTEfad!ty!`cA)2LtOV$7`_EIe8C-F>d*28ANoClpR{*jy`Y;G2{eX7o+ z1^H8((M{*Z;7be#+G&K&d*5O|h-ZF&a2L9ekI~c#%}%`y0u6Gb<}zZC+fikuUfV+7 z`Z(pk7jx*$aPEJjtb%R`Qthw23~r*~7$-c7Il}eyvd3x0nmOBV{(AUA%ifyyp&X_H zdFRZ-VIT-tt{*OkPPoEx>_>z`K9;0A5(RaD{n{^0r zz7VS0pqnf{ukneUi(yaDT!#!HFiGdzqVL?K^S@Ho|2p}Wpb>$BrB(p-e{O&Tf3Fp$ z75YtdrF@L%Y&YEUXy}Lf2Dmzh{%E`k&XQ+td50N}rYw5T*7MEF zGrWrovv%$C?`BFCY~#na8f7u7s=r%g%4Q_(NRv3k_71C+`|pY=G}`fBl4ZM}uKn4J z^h*1&FragP<_&Ss-K^_>D>kI2j?yDwnTcBkx$rue<^I$4T^6fCbph)2C5jrXohW3f zSsNJSS8Nzq9Jhic@Bzs-cvRGgUO^XKFv9`#xTagP2x!89nq+ZkAXZwM{`J>;O^J;P z0U|u8yguZIdQ+z%8X<1ja8H9QAaPf&9BWrhf27kYLh$`4Lg-6L`N?NpU^m6B%gmkQp8`OhH|FXJG2 z)_F$}CWa=_Zz_2tpcO%hu{>da<{6`RyHwH^-WrW*D_pNDnf*_+K?iYuM(1;sh|EaM z@|kLWo?X*xd>c4iz3$RDp}m9kAhiga7XZi-YUpbSSuF=9?3-rpfoo9IAa59#DP$&u zN4T{#N(I*z4J8wmT{)s-QC10 zCQ;`A|7FfQN>r@kuB**v+Fzf*lwO4>E*h zp+>>c5xdQ$s8Qc4wWwNSnfV&$grKL{eRkurWJ3D=Yy# zL6j8KKm9QI|}3k_^FMmHC(p7A#5WR?5xmOv<2bV z?hT=Y48i2I6kjpwe=c-(IwNUGnlz^ul;6bhTiwbMZlh>$;8O6@(@$}GVELtaG21>R zOK_qdPgn(K5{>S?r95?c_O+@*wVobm zQPJU_$w@e8h|6m>JovUBK(XOddYubnneww&JCw%RvxyX~$s_z>*$Goi{UgNiXW2N=wt~Vswcn+kZfu}>s zgFVy+X)}MsL?5&qUll2;M>Ti3S)8#O1`^nffIe=>W5KE|HE!J)ez+1ejlyX8vm%v{ zX0f49amtbEy5bo;_?mF?E2g_rJto`%KcTRs!P}r_+w1(evElT8T&QU%S+ZgHc^&q0nj*{po^5bNd#R0V(nu?6|Z{ax~2iwAx|FKt{=P+XrCT>Ol=V9u{)M zO1gEoZRmAyEAn3054~jgSk^zpAMR!nBEeQI7aC|#c{;rPp{x|PEziXi;>{0+>!`E| z1~I%Y;Xg5`sCTx#Gr1Tg)lM1w4CQ5qyd0jd@7D0oaX@a@bP~fk=8HbC*~E00nQGYv zE!4Bn+Ks4Bl!LLaP(npIkKEm;U6M*tct3u$?tNNRHrEvWyymOs^C(Qi$~9e>Fq4~b zu+#)b3jeI=`0C1USMhiVp6v(9jgh?qyGV+2{>TXFILz72SMZ9H^V{z^Hhbq$>6!JI z;|J5Y^4m<}2NYEIIuS>B+ar*=&3bF)maazGs}LEvn~8@pxguwI68rpm@(fFj4j=F6 zhK~2WzcrwevX!I~Hr(L;5p?o?F`v0P4GZO@YEYUi*U-t$fo+ko<@_s5mS*fTtblhN zt6nQh;39!fgsCgu5f(^sA^#P-KEl!ytz{SYz#$a22+MIM|G+MS_+qEf?Xbm+aC<#&Yd-!F_E z%$bfPMhCG2i__tC%@VrJJ6}tc7%dRdH%=y~G#+h*jMToaZ3Y8cj`=>Fg~{%TK9|)#V+YU|ILq}$oaS{RjoE1#U&Aw* zz}PY0YhiY#5COLy3s>4!185X%LyFD`Mp%z;wAWu=A?2INLV! z|6gxZWVu`zW!24yc)1T2*dVXhVp|1=#AV~z_}HWI;bfZe)O5$khtATe`}@EspVkHu z8IPbQXR%&=xGB3*XH;0ZMQ*Zyb_rqe^4iY>ooLWD$e^^{xJYIVro~o&-UGJo)a_}w znQfFBA|pY|k4>f7JN$%RO&GB$BeCqxPINQKW6mT|X*hJs2#N+JDc+F!j;uY?qcSa@ zqSw#5R0#_{Q+7)o+X^XDPo;+{C)OxYm1&r!8w=k*!{hvtQSe^DLJ2kyGKr7fdLloT zJRQc@zq17`fW4b`({O&s4wnvdWFW82vRvf$#l>RDo3(zqgs#Zo&Md3st~+z1F1Srl z@c$(Cf9(CgUVsEd_RIxC7=!M(ZVHddS^A7mn7Iari%DB115v}>UYl@kZ#TWMHE!{; zoa=>fJF&eWy%OUE7w&$k_eCSlN2c-m?TMFh&yPI^Pzi}I6ONKoGPt79EEcznj2i53 z>C0O7_54w1EV1!J)O~(gTl$)n0S?xy zmYu9afUb(x*Ok3}w2o*17^qI%+#Pif4IOqw5AV7+YmGDdr~XQ#?hXi)CpX4DhYQP6 zt~^Zs#s47Yvszh{W~0B1@k(Xp$_oi#jCVRO(mijN(LNgQew57c}j=`hG!HhSo0UG8^Z`LdiD)!6L^m>PG2)YFL5CKG!-7vQK3lt_HUqM zMpU~gVT`0>6>c#REx7QJ3~Ca&;5JEo^onv!Jps$tjJc-ABEb%#Gkyp1Fnh5Ncri96 z9WsomXPRiRHR$R!u!?s%6J^-TVPnTljkOiGZ-LGp@wH}2;C_cHc%>mMGx2kSP)%VY zG5(BS>#G6Omvx7en$kOZl`K#2yy#qBWd0+r6U;N27(-E|slu?v*%q-DQJwQ^QC4@{ z9ock~Y^iBm_SY0^1O612(Y);6cN3gSo+0MLzAAsiaplRm3ufin zTPjYLkgbL~t4P2(jPE@HBn$r-MRG41U%g9QMFd#^gW&ElZZcuGfmVz|@&PLjDB#92 zgt>-xXTwH;P8p=R0VHj43I+_S*&383U|od87pSNN3hA;)#3F5TY(Lnnh?6_#J(tC4 zb{=chZQwQ}2#G%Jn>yCe1f8xRh?bz{ zZL5jTY#Amh4I>%&T0RtwZ(_cxOJrUOYHm;hU(_BZp^0iw&f9U1(WTFYLj9&q z)>kLMc!eqZ^ubl4&jLd~uPvjRC~2ai+-d!#^MmB05p@|!TjBEiGeM6V;sKHAMcobb zvlfgvMx8upncJCIW92`NsB5fmEVh2oRb5}4!gyY0GFNMs!%LK@{e)-ATTE;C1(ZeG zSXRLrtIILB=jI}=|J$#_*fnX~Q4->O-C{Ok=3MG_<8l`ZgU)!|cza6|hi7ZhL%}T6 z9pkpbzr)YUdsRkf1O{@V?ajDl8fS$&#;h4%%r?k3e?_|ve0gD9@kC#t*7eJJWjiOg zO@koqpTegFIVcQg1)9`mL{Z2E`{6O}c9@8?`Usi!4vBO)eFv}ajXMw>yNrnht+5;W)x`{^R3g_6ZtzZe~-pYyTt5W+%>y zy48V-FXcaVt;hm&>Gq^1!%D~mmM`sjYi1f(?S2E#o3G1LPq_Io0bA-zwEIF;!P%w1 zj@Rz#ii? zX%gb}2o4dzw9MeecIegF-r{_=s_3qsGJNnD2=<1g#hR=+w0Nz zozBi{e-)n?5VYg5O6F6NnT9f%`yCr}mx=~-m+^)daf*k3T&eAf7>_oua}f%I85*5v zQrhs^T!xDbwxW@yD`wQn2J@5nPf0v%9wj!z>2ChsDW%TldS-&jL?xu-))x~Ss~f!4 zRh~I^34?_8RbXe72-JFe z&BeK_Z-4&uik1v*|K^^SGrMfq*d#-r-?1+?E#to*M)#C=3*R0kn#9qkR1IxAUcdADnIPWswNkKwLgFZusaUwu0mYi@YiPSBOF|BM8bKUl9{;?6e^*`?Twil!!M61>r9?z8Hun74*iIPX zL^G3Mc=37oRVe)ARR5ymit233!mrtNWV-t=Z{n|q54HlRi_p!ejtsNSf*f&PBxw(K z0*}#Cmc$QoHukUDgwFiQG+7Xjp6bo{RncE7vtAIr3)~2!EGs;Q3FLQDABkC80~QT7 zUyt@v{RT3fOXY{{v=aa>?*IKz+uuC8?=JO8p4rHXgv>4bHS73uI(G!MunOP3R*;K= z0-+42Bsher4H+0(iCog{uXNEBZCv%dhWqpnFHm8AQIvsF6c>Lq?J0@Vg5FMx{M}kBDaW9(ma?PmI(pE`e~cLYuWIIyit=Oy{jt#S zvl#o*X~E%aPhknSxH|G?sq=X<`|z*k)kNKZgYy1=ly@O)SwP&XwJkLlpTwZyWlE6J zEbKDiJ3je_G`o{B_eg4ybHRcKJX6a)eFW1a{EnDn;I&`6C!QSiiMjjBw2W61aWgfh zB|$!1Xut|Im7^BW6G~?cg*-OSAnUj~ssG7Ttduc3AqNqEq)Hz$lCx1WW8y3{vHd7S z>N&rOSOiMT|qD=m)0&=VO)vqM5IE)0~QrwMfF!f&+8{X3YdOzgg#P7_O z8|1VxoaLYPpVImqA9ForMcC|L!-3v=%DPF4fBLhOu~13AUd{`XUU}iO&*L8@cOsWQ z{{gZF03I?J`IwMzQfdGo0bXRnKJJkaMq@m;0|Y;^V9Ll>#Adb7g)<0gh7->J8_&@9H_rf0HlB9de&Nk|P{Tl>Rpn zC&^+{`3kVCN4-PrbS;9;kgYBcP|jXC#iR?J?7bu=jJc z+RnEuK^7dMc?Ca8zGGlM$s)u=qH@U8@yxh?A3KE>X(ZzlX1U251r5di5OEAu%~On1hZN>#EmK%*RQI@BA>yi+u-zBjPr?3(YKiAv?%rk9EFoiX!^&0r-$z*}OB^Jdl@&bnZKR0k7S!pNQ)$K1YZD zwfjuHyl`6-*SEbHiC*09wncrPqIS36L5ITl(AcwS(Cn#eW#mMCGAAJIbe|Aw({z@8 z(t=^tV%b!R@l6!{J`K#q_I9PYtyrN-O*Dbsf8w#T6G4U_&GF>il0!ctQDhW2yttWA zbELg}%Qis$Ee@%P1c*szb91EbpEV#kAG zqWZu+haUv9{C6wuBin51A5Av`{uZFs2l79%xwMvhdq2sViY&vo)jmtr=UjQ*`7M@& zy+-T((wN6QC#Fh=s&eq5eZJ1o?F|dEGUICQxpnTs1;|J02N?B_biA{CV4@C86F7|)|xaR)7k{qFWHQX(al4;>! zzK8UZ>djiN z&eD1Fu|1B*rl-jpil1 zde(Rgf9RlvR{tO^^XQQ#FP!bV(CG&?UGSEFX{xhYgMaXA?NX*1~k%jXo z#HeiTu$;dzj@IOPvLZ|1F-Y=SoC0d*3AFWtvsgv58xC$gcL8F8+OPF$GA{zlC;6Y! zlmC5hpNlwm6#t<0t~a)VY=RCjU@&j zZGtJ$t!4W)tpLg{9INzV6nabvP$X&5%25hec#7UxXyu=B*F>%xtfWI*$ z1)}%alU}Pu{K!oHvS;YB4$uGhoj^Bn53KS1Y^5&fYxiEq{P(tl^P@p9S350JDpSZRt8uHt}k zM(0TxV4QIRKTyh~{8JdC#ug{arpI!P=A$T!Y;ib#EnpmR&nXk{rN<(BD-$0G@bt)% z5gU4taQwoyDCUxx%GIv32=d=5_{S&g|?m{PD+|_;a@fbeYM&YBePuFs^6oqrXcTEwv$gFi&uRTK&v-E zo4*sDoc;87@+-y5tnu3p7ao?rL_d-ntV7`>d1#o#TE4*~UQR{~J{cu0J|8nWq7OCIS(j9(9X?ZZ*ZmQR7r((8jn0{uAj9ET&9{-{ zY8P?NZ;wZOCiGA2Df!*hKYR`h1KDSXkU)=p^$B8cRP`EwOL@Sxg&yi5&>AtSZdDUY zeguy4k>-4&4$)6y^^so|%(-KTm1c`9!55qQI(WJ=BHKSTt4|H>o6_^hOY zwx)T;r;?4V3^s7?SW z+n4ku-O6P2s)|squIz}j37U7EGlY3MmTo=bCy7etAPtFS?)WvY^ySmgH?Oi&-rw1& zP+}Vb%O%fr#V8aHqC<0MpYf3uDQnd^X0JO>HIs6$i=-v}Fzdv?h96`+CHPMTQeW}P zirSBvE0iAUQ!an~Ds}&!iy;ZsG1ZPAId$~*5wk_4Jj0;_m1K#{Qv%O{GYY?&>WQ?C z((o6;T-u!typgSHk)jgfvQ9kiVrZkMMr?Bs2AYp7a~=Hc=`@h zgrH9u990L$G2{%yOF!D1rm;yGTmdsJM!}I~Sg(RtiK*tEpKOZAj36GN3O;CHs)n+! z=@Vqz_V*PAugA`ahFxdLhElU)#TTH-{p}2t2sxpew;ndtmmet|EZMzWEL&s9qTF9diO3%6NPWj zFTTY@;U}Tbj9iY^H{a&J?;{n^`ikQ+D%)M24B{DWRzOzXy}SrzX7K-PId5px(AmcAu$wfHv&|1{pb)g@ohNDv)vg4mPjBdPdeRkhG zu#$nb#jdD;UKvuqcDp^*W+Yb*I3Lc}_YZe>Ek51#8Pzcq-rp1cY^C;N40-jO9hvhD z=@gM-3Hsy75T1dz(=~v&qOU>y__F?j`+@)=JKDO*DpC-nEjk+A9I8>>5(*rxPa)*Q zPO)TYp$(8ftOtx<{6b0ra{CInG=nc|R9%U3j!0C6ZJ1nS$=6JqkT*n~eTZ@tdn|VF zLhEP1Jq=5H(Y$DKR-tusM!C?c|$}3(uijwKJZE z;~94A+m7w`|ES#mR1*3(~*?342;-0sgyR0KU&XI*?4DpDZxcDX}fiG*S+{5tt z9E1cd6HU|G*U*@VacSEU?%J)B;m`6wi`lkJv+GoyKE+ok*Z6eoYE!>c4wB}v>f@8jzjBFBLvOKahc0}~%MxA-AHUWWeH`&z#yW+spKVeT zpLWaP&5r}w6^gFTQwrx5#c==pgn{C*x?7)HDxMcXZ}}sWm|{3|4iQ2|IIo@A*q*SB ziMLy6|B1Hh8`5Q4yJ~m`t*eRL?j=`i5jMOqqT3gGQ2#WRtxep(0rYA7r6`2%9XiL( z;0e^kvcV}`+a|{AR|y?N)+Ucu@C7<}nH#Z3maH#}8`tUsVT$%@V64ldtoOuxt4PW7`+Ti2n#B6>f0AoCd7v0o@K18B0I<;QHdOQj z1mcj#PQ5`BQ-Ky|q)M3l;rcgcOY(#{jz5K7{D;LeC12Y=QD)u#Hes-RYjV3x&DrpE zqZ0Zo+Ujq&{GHLrJ1G~eX)#5E4Dn5|+ul}OSrO58ymI!83lwpZVT_|k?=m%olY0NC z{Bl&1Y7m_Hlm~gNCv&=*-1lS$2<}L0069>7yv}IB!q0Oc`e79qKE?SC!cgjI+ z+TTw_H&s47AG?uo4GsLTyk@n!$oj}f>7r6bN%qoGium2B&+lD&*r8;-2H0PnG;PuD z)V;&JzdrBjLcJ+bQK--##l9mP&aFc4phkQbzTlavV;>FPkQ|GbDO+QDkNH~D(qTZ_ z$PaDk)IvE14Rl=Q#;Qxl&i(TJ7Q=)4c@lj$D+#SF`p#22-jU(muf*ujapW1W$qL$` zK66FeA`TT3ZuqIRDzznb9es*pt=nrm#Wqdlt($u?KMnBbOU)rBl?sC|uHMW1oD2+TD; z4z)K2V+euDtZ%Tl-1nG0N3Dg?5t7>M%T6DC?deq2{?>c8DjHQ=RIje8)YbGUqzc2O%eO$103Rs=b}=jh zy9QLnzQvZ1ai2dG190P-mdo7bXzuIin=bO7)!F8kByjipy~YpKX|w+2=q4XSP>8+j z%0QBK6^d|{P7pnq`N{^OA%X8sjD?&A9tK{89hY;c(cCox{~7rnQA{vnakN<2-cWJ2 zZV6wdKPwjOj%PuX?}koR(k@(N|Ab)$Z^uyaCX+Q^2r>Q+q-#bm9Fs(%^dZgpE3chz z$SAcSmS7TjsO^Q!$9jB`$y>2=7tg*-RZPKVH8^>;{h}BgqemOfM6X}CdQm#YIlHY# zLu`jIRiw;0Gi3&?kDe5U-I_g8fu8Z#`n=w1td0>Ck^Unb@85Yb8Tf3T)@u||Cd>Pq zU#DWv^4IX)haf)fVmS%iP!3|q3CD4+gYeFkr^gLB;GCG=HOW0Rixv8*pYfXS4N1a9 zzTLkK+#xQF6SiHseyiUF$h!bG%57UbEGli9k*BVYy4JYDXv<-1Aw16r`Kmze{Vp%= zK8u@_`veef-gQE^#`S6f*m6yxEp@HAts4((mw`rwxVekRr~I<_Gb0+#rJ>fK2asaY za?UIdmXyzU-f+=bb=JSCoYE*i*Hhtqix+Q}Qj40xml2%7Prr%{&)@jZm#&CklA?x^ zMa-+k668SPpVs&gTEJRqv0E+ns{WUcQF`v;-pZZiiVr;2&$aSpL;F~xXofsnZX>Pd zC;QMhY=%z1r7|~M2-ND1RzZO1yea)f+U%?N9+ktKsGd{T4fW)&_h%!D%(mvcTQoNv zT9Bu4eb2-GGiPxg7zZoQwH(b}bCM4R1(L@})Mg(Tu;t&86~?Zxlc9rKFu^7*nAW?C z$Fvyrd1Yld}e&6(gHu zb!-5Se85UxKTeig?mo+{eXi(R#}?~vH(te!75`M(139RYtO`1Ee`nMKKOOGkTYN|V zM3S6`^!$d6YEdEy#Dg+zgOS03N%`qXNL8H!fU$;*2~k^#d*Tb-m!)3FV*N~WI@IP<&XQ|lx?H5B&to123c zWvq%FzOvivyg!u^xcj^O;z=xhlynAnImJttJ(^$i<#=9}WBs5=#hj|av}&C_czo({ zG5&G;*avmCznK3$9c?aM%n~P#bNGAX z%ZE%;=B>{MjWH_^6Y?(BEZ9?W=upfz#nCBqnxTaO-y<6)NBJiD=9~Lia~Lbt2u_iE zXCGdopV?e@!-x3!MT<(xBLUCw{gG9u(+AMJnV|ur0fhcMx|h=;c7*=DBKd??Ir@Pu zPe$a@@?YLq78RdFc1ve_mkgkolA3bMtN%2%a8gYWk=g^^>lolDL**l?sQqmMAvGUyf=!yU1s$@R1nf;@2B- z=%X5k$PZV%$!hWo@OCKmKENfes+kJC~j|Z~uM(zG>mI z{7vi09cFF%21+5N0yP4U_<6}qfwPuBP$>gK#Iv^u5?i%bXhS#ixt-|^Ye%|wzaIYn zw`U1vAda$E0hPZOc+BH-Kh~@}4TknVP2gbNmQ#EA?R5WamxHlbTyOJ5`44HeEC@L$uUD;634U{$RGpICK-g};! zaVa9^poc1sGw&S&{Pv9F{Z5X0jx^%u8d&^jJQ*1MXYZkGqqI2nrUo6sb}5QXal5x2 z9j82uBb4S!D)#H^p4hzR=xN&vhe@5hy#JQtAZdqtorxKU_h+^oT3*4K2I7fYQP&Ok zmPQlQOh3(k`JDh(<PO{prNtEM+_X<<+XZW>74?aL>LZ@NM_3k90WX6i#cIFY$AF z@Q!kVRizSI$+u@uE$*bbDq**ZL`qDXf8qwt!|YA>LnaqDhPXr;^9Cd6>Z4)nUu_jm zX)k;`@5qLAPoB`@kTsYvHD=JW{e8bcK*ADmY4Q>sE(#_XMdA-QIG-9~in$B(|G zPOm5zHcNv>aRb2_nHv=Zwdkz z%B|6A_?@70%j+X4)J`2lb8oc&qXxlBfQj$I;)$kjUZ<2y?2G8X6;eCR;W?42sJ83(D(WNj^jOY z`n&8S4&+Ca6)9&IXi@t|#f7EnGwvmMy7Vul0GS*CA|&NDOiGk zujAoMg1AX8AJqBsUDC{$1kS=xk9bUM47uP#kce;#&J<3~YTL5S8RSga&` z=>L!HT8DpRf)d15UdEIn@nOy^po35YQUoWCu=r!K$18ca;)(2*xOh*pXd9EflnyRv z|9N6P7BxAlP}uwOfqFwHJj5V{fIC3=YqXb02yqpWh=PxD3w}u0sARSBD_fL+l6`vR z#DweTRJnPo_@6WpgBZXNA!%%G6d1=>W;i_(Hg?J`NHf=9+=xdOy);vBGYm$B$u7NA zfxokB#>wU}L`ixVFzP?YaQ`uE30_;CP6a8y60faEtn+MQoEVICy3+>MBRT?rzP$XA zu91(wqYbI-NnJ#Opc2iv{m{*6l1%CAVhPRm1Z{tUh`O4d3+yO2f1scW-C#Bf2=F2M zA^$lAP!<}gQCYzeA|dOF`g2dral}z%Qvv-RNhdg^R;v6cLjGQXn?Xm;>~jV_^+~nO zo}q)Bl)y+)RK=Aj+o1eUK$qu@lnwzBK#L`ZhH;gL6%9z#J&dAi%+tJp4`#uv?{ysT zjusqtd4&?Fk>Rso7)V#(VEqcQreqStg=oNluR;% zI%5N@d1SKm^B+L^Q^hfj#Rq2OQEAhA98ymCe7F5zlwyZ$mX5>dy{!Cykk6YmGfpuI z7ul-GV1ghx$O=!b}1QB>7wR=mz5t{tH&!|8GY;*ur ze53gL$PRVTCU2jcnt9;!ht0#(%SAn*M^9t`vEfkW9t|zk*GOs9#S=Cx(Y{1)O&ib` zU1)DE_8Ix#LNm+(rJAC|AupZ7p(>YNhGBr9f}dp=UI%Dwq+uWnoJ zh??HdZ14LV}!GU?1vpm-?qz{U_;j^W8^Wtx08; z_P#Y?O7$P$%S+}ViP>~s)~hOn?a9F5-*cCxeTOiV<69I@=^#;;875(>q8h=?x6$+x z;en*2_5T-bn$p@*+2GLH?VuI>w+c4Ww&h$|l+>Z8u!@gZngdz6m&ar#t1{SQ48-* zP6q80sh9>7-yz*>-ZsAtkZasuBmA*ZoHo4r8ksNoTHIym4HuRkN1pqI7rvd1>%iV%SRGThIC&&-ju3bAPgNLfhZ7C^V8K%>Tnayqb zu|5sNzHY7-7u?15*s`3X$YU>gFeJq)n>== z?A9#Q{Q+?h85L{DI&Sv+ouLy8^p?ew#45oSQNPK9qAKm;l+=~%u=uvh_#En z@0J96Ep)w><0Y>8J)u@I62I^I3FVO@k6sJnvy%D!I+rSjq0Mx$OT~(pByAu2r&lKm zxj#u5OCFtFCN>-?Mr7l1PCaYBcTA8*St$EclFbB`f;WEuy~NAef;L(qQsOb!${cjM z`1PWJGUd9qE@-7Z0f@GJcB^mHO!xL`O|a9!KyHgtJNbGSK+}5_8QSIYYVol4qhjpm zf_@7;0_L|Br74}?a$u6YVY#rTDoKPO%HVio(>7nIi`cqytJhknv5|F95tPX1JD z-Q(na7-6+6i+{2xN;D;K4#0HBB3d$2;^ByAOewc>zEr4tg9$_&Gvi6WGJm8l26354 z1=Z@R3qPF(haqqKvT9Lzia^;KT{3uM_I z`lfimh7RXZ%XyAdsq&RL`F)zszw?l!iggRpcyuwILhf<+P#{ImN+qwVVyc?3*N5$Y zXft(9I%V1fbv)2j7%^)mjZ1GLleQPqSl@rMRr{`|*ED1KIjQXx361;%4KfRNyk5$U ziBQWc%fdxM(q^cQ>Q_V4xGgv{f%wI9nJfjx2c~?=McX4$* zH?dfYSVrp|Qf;LB)ujfzj>;LoadvUTDwRCErkeA2^hpie(S~!YlA+kCQTQ!PT~;lW z4U+S?jmWo**tNRBp+D3qPua2lWKiamz)2`ln%8>W@*xr6z~LKVC_;fFzISc>FKVY8 z0Xo#f1p*34>15#Y?SD}_lMf#nfBQ!OJ&%NuyTU);9X!f0W);iR25I!VL5Exb+i$+} zTiisC@u{O>gVKz)R&TFQTg7wGsJXPt*PQWB@p_TxKfNon>2Jw%DWVv~KmC;~Al6*- zNshddw@MXvB#F(IFnqHb62%6XC-@o^(QiYM5@_%%fj!Qw)Fe^u002cL!HZ&2UqwN6yZFp}SAQl11iQ zB)G`@zaKQ3k7gzJy|~f07j%Zc^%%iz;@%(Z!FeLb){WZ}r^sCHAM7i%8W-I;S^6Pb?o-4U!ad_cxH}VSbuV>$1xIKCH;-Ry4 z>X{m(NLps;W|#%le6QP>y>z*9IlKK3PoZdj=pl9!HFqZZ2)}V<0<0X@NmgQ4@#HUf zl_>}ppI)|1tt3@EwbH_aYFduc&V+s0rSWGP8}i9FDZDRCL3SU>-JOJ1z2h~*7b1|R zTU3vc|6J#)CO_u@AhfSvP2xrrz}at6A^^(=T&qm?bQ;c*^69^euNM(=7>Yq#vIhat zO30`vMTdwr>ieh~{ahN57BBMi=QkgaJexM%pQdX>vfFg94ku%e+URc^k+L^2(TmJr zu0Q1KOWbk-N;864kaI#JThD4aK|6xtcTDAGgJ}cuf_06m4LlFYo2*Hod^@*IpTD`+ z$JQd^ozP^nsU|<#R!@H~e<$3ZW3DY46awFvyL9(?4(eU7@1Lj0+1Ll^LI`>s7a26I z3a|H18rmuuB2ntTac1~~i{>wc^X3&*`8FsvE}FUheHYqzILSx?NA;#{@yV5|FJ{nM zUptrW_Vyb7l2^k%mLZ*U&Zm=fg$$D!vjJ^c^~9V1;&%x^{EqpC^*(g%xlKIc6FZ$A z>Iv<7Ix$?nCLjsgOAd%TF;o1d$ft=CVV(OaDcgi5kGQ^%gac7sYqz@iQAjN!7$t3|`MES_JC|e}zZZsbD^K5#>dBaga=j2aiD6_zB@VgO8OG8rz-(=22Li9>eoBrM2!tuKH= z^k>N6(4M%C8n2A;9FIC=2>KxlefI!$z&bcj5f^CXsz_boYs6oqg}S5PPr_8YyzUvI zNHd$>K+eP=yGssoWLQi_N2}_-1?%6@M{|l zUf6M(OqH(=^bN)k+sf7q6nx^Dy(a$>p^%8tCQOEkTwTw|P=CSel*(Nl%6qxzkZl&| z1vW9P&{!=IV6m&VLJQHv9e(~_aTIm6_VEU0+74zrXvC6>&58{JYCH27n1*pOnJ>BL_utU|VgtV6rXLfXha~oJ2|A@Ss$|WItMwx8?y~Mr@O0@&0-Lz(?9Bb$#k_|AexxlNU z*N59ii{;is<)^(|gOe(6>-#Hp0uGOKT1fR(ib!q?%q6x|q!zA)vq(~Vf6X|vj;!vI zzx5YH*ERtY;aVt`cg3#R*`5)PahFEZp7r=B;WzsCP-EFamvXT+2sJ)1{z)}X0ta_y z^q(PdQZrT~S0mJ&5?)D1fMLPKq(pQxDEXI}Zz3se-bm2t))V^xoH+GShohzM4upxo zt-A6QG~6`elA-?oe{s1{shWFIg_aTPe^`Jy#9#~gMD@R5;8ft3G?Hz%$viAS2+S7{ z{O6V{-vb*~15-k#LFy1iOFF(@Qgo>?z9{eL;pOg7?Y{=k0wGK2xl*mH1Yz1Z)eQ8@ zgN(Rl_J_PDys3X&23UJ0^!FYo60p zd9HyOv-XTy%IItj0-kz@Zwu+guA^MM+j5(yZrJfz{0J7JKmPs_V2k&w6A@tMLPy7b zDA)WsYA5668l^REF_c~2gPcw3!Ta_BQ@7TD+X<$Khot7`>tnZEPv|>*?NcQ>onk`=&#KbD48m zK{a0Tex2k3vn3BL?M57=FSzzN3Jcb?F&#(0;V)_>s#4*3K@@T8`ZP1K`S&7M8>L$EN`r=4 zcU}w>58th}cVnZ7O5C#GR%K8b`iCGt@@B$b?78ko6lpJvCeF;CBXH?+XHCm^VX=6B z{ZV|w^7xK8M<$*(s{v@2NAG(Js)~TTSs__!l zn8AC>?123D(N-9t#oL^X*8+q?CGKr8@F_!f0iuvBzFxraj2R2L=PLx(?XR(1(IQG4qOS;&p{3#>)Kyu@-{ zTNbzu|FkTswsVZ>)?6wfukLjpJCPI?JUMV${<+J3hTH5d^Vvj5$IGiX*Z5&1N|4%L zvB%P;+)icZ#k-G}bDgDc4(9VrMwPqxoSfu^ll&ZqVOD(LB>Eoga6}H&2iGsDhs2x) zIthS+KzXFB%SkDKs`nWQl#bm$3)l_&1!-sDgqyT&v9Yi$-el<9o@<;7Mxw}=#flRN zjN@A;{UVWpk|{&V;Kb_9dewIlf~aROHAt;qzKArY)EXIy)2t9}1`j>aON@ba?t;#3 zC3G<)x3Z4*JCAg|Vr(KjzE=0OGDHeFE`%(BtZKQ-XEGIDnC$q|ZKhem2az5Y&ZECz z-G94i6Li*Qd&gTRSsWWX7^MSqwNP`Sm|RU7gB`<#6A2g{Ui}xzL|f;?E`lE`Q>6=! zln!NP!{(A+N`2bXbf)WeNV7>|?kJF++7h)n`jC&eLOB=0KwFo*_aR=_e~-fMyCqtJ zVs)!}{Mun(ekp;tI}Y)4HGux`y9Rt^oIez(T?Fc-;*<(Ic-gSsst-wnjqGb5Ecl~B z5B+zFM!i<2x*AD^FJ{1i^E*Id_&v*B22aG1QlbL56zWvUL@#L4bJcu~7oX^)sc(Z> zdY=LD!xRab8Tmn1T7qrxk7%Uh=4Ne-5qI&|V306MsOIE^Ei2`AQWY9t`qL1e`K?Q- zrT7KemF$t0lwKhcQE|P!y=t%m`l)OSa!+ccU72GtMQmg?k&g!5E^CYF)RTWdQt=?% zGZFKScFE)PCly`ROdS9C{aZ*bL$xIRBjvj(0B_&OsP-xkKZ8$iH?Dp2n!!@$59uoT z=5N-+-EEr06T&98Yz2KSrDxdW1>%H2l|7Szp_f|M8w_p z{-O`abi;Kcv}N^QO-gy11-pl0O#$|Wg>ercA3db@1+OxI#XX5>Y`f8KZU8Qb!ug;2 zI5HWQ3ZKI3R*pNS%WWG1#%)cvc;}!g9!W##KQ8g=i*H4rpb0$0c~Q1t?Ir$4oLC2h z3~Y^v#7r0Sq$N<_^K12-8NzvmN^qhGzk#=I%19Z%z_XdN2G$>}KfxDKuY80_Iqb`M zUk%#f&N&Qr_gO%eR7)OyXSdMZTJI^I^j&lE*#Xzk zsw~RjyEtPzi`;Cf_TJVOOZs;g7|adCAPD0KfW-HCc%p*VZIjWw-V)xa-%3+2l8TYj zURpa>%Xz;$9N`U&CQ((w%O)8sX-Xr>oP{LKrHg;TrqOF`{fjR7V_>%zS(j=SWW=aH zbKvukkfoIsQ~mC-eJO6_#Wb{g_?TCmI;AokZ7*!L^$(ppsrg)yVc;n>Kcs)~z71bO zis5&a_fVSeDnxf8$Y#FgrYMQU*b787#gm<;OQiOjhsf3sjB%m`MwSu%aX5seRKn@f zS>NTNd%T?oCN50$R`kq(_ngGTO%D(m-I+~B(Cz-&s{ipfd!%DKp77<0JuO`>Y$ioQ z?Ojq&=v}Df+Aw8{13O<4&vBpBjCuwPQoVZf=CjK~a63l>)1_zteLSqKT7&CGAxr`o z{|>sjoQ4jKRY`5GwVGI3=36W^y!-sTUoLKu--a_=3H>GQRN*T{oNr?HHm;)=sTdCy zn(0>qS$wb9;97vp7Qyt5=lGEainM(71#H~^7;s_k=U&RNr9{17c`Q#oF_m5Bxbb{m z84WF%#*RR$Cvfh}wTX+_>7jW;)S0wD#r*8HP>?PPpInqap1<>NrKl4$-!Ilr1A^x~X9-ldV*pG7LwUj3s3`H3Ri^uZ}&fcrb9D((3Ah(9pI`yCRs!CZU% zFiqVLuueX;MP`Gs_%y{doxUl+L_ZfDZ%$DUQ8&YU%HcVjJrP#b=okZ<0hjbWb3$T` zBG>r&QTz%*kjDy`1?XuKK>LFOJ*_T4er5w)T4gv_hK?65x@d@336T6UkZ@j1q2z5a zxp;w73*#-%pLNVs-xpwZAN#`2=dZ(2tNL}EK6U=Jd)pK%Db_bv37yqsA~vuG^Jnze zd~KQ+%6#+`emL|L?(cWJ{dHdvbYb%D3%5Nf=zC*(plls^isWgdJB8j108w5uwoE zngBKL=Q{}>g0EZ=Gqxk0KKxrD_@w`D#cwOe02PU$K^2BpC@qo_jQ@WYfXjapF4?7N zBuvHjUwhBG9S;LVShnNYB8T3cB&~zh<0XXo7c~=Mi~q@1vXD=B!<>bU4I`8ek6c~j zALjzkA<@VXQ@z=isxIpD<>jN6YKgvj^n6rk|3%6PPrvBrEWCJg8x^K~qbCX{&dH&j?fdT_4k<=Jaf` z$+Rov2XMpK7`lRXsCGEcJ|Jw5F-%rGl5*}uo&oZd_HqtI{wP&2ssG{3Z6Qdx~28LG`L!iT2m^ZVy0%rQ*+Q~8R zLKe=bJuedUUJU|p$a7)yDYq}_ifIP$rMBdiG`&D(fD^nuI~oRV^lVaEc%h&2^sN^H zph$fe04P!-@{j>}!|?nmUKxMnfB>xnmG2CV8szmKDw7utlgx+Iu`wB+t)}%MJvb(J z>%>jSSFP`*l70v_&f0V?Rrv1GfAC?Ic$vQNmpYo>bsW*%&2JGLL{dli2qgPlTGM`k zCD&~7V0Shf>FuI0&E@>NBX^PF&13!|o`kum-7M(K+P~CZHNo?5D_|kyGnDtj334Wq z_b*C}8$Vfopr!RCYq5l~0Hkj@gelvcd?$w;sO2?RJAd$RDvuosiwsE4yPYuLP~RLg z2muvND}XwGyfEIl$j>Q*JVwKd1~?x16TS^d&xwO+3(TbMdYNOsQ!6z4W8QyEa=$!W z(ex!TNxJP6QIO3kE3N@6~k;T zy3`8M=5omUR5Td-x%Y89w4TU6SU4i69k2TMpu{U_VD2lWGAzrByG7{Ghd3dIy1|O% z5wlD3qF~zim&>_Gy0T5%WWO9q-s?wo@X)@+<6G+-5w6$W)C(R>{bgd}^|Pp_+c!eS zBctcGIqZVYmn)xf-iXL;h`;-mRFjrVa9KUapQfEOx6*qs`%bXcJS`0O@#_Q^j-F%3 zT|@dH?#|>y*zc-U{2pS`VM%@6)oTGMA;;4W&>K07!*8+4d$-#6ER@Ejj>#R+DA0|(Zy`Qz!& zS;akeND}CUu4QCov}$I+Jdn2nSwuF-U)PGNcN)TSnIEU`gq{YTvLqxVTF!)L$& zh+Bk`n6RY4Ox;ToFqX8Ea&Kmvp`CYnj#eKK%6oyT0inEqhMHtTkZNpJvUQwS=W@DtT-qzLim(h;vM(iOGtpqORP5E0D^+Oi;*(+PoA>Af`isyE2&%nPTL8&w zCT3Uy_k#QMC^90kN>OW!JfmHE;wW=X%q@vFugsFX99w&_-CT+nayj1%nlbE-@m>~W zBH+pn1FIhTyWiu&I;cn}k@*|yr~9S4Clq?~>wb>Ej!a+Vy-oc<5sY&_Ui9gFs+&>2 zs0U3klscC*4EL$tQ8q>mqZiJd_p7SeE72b2D=(<9X@@|2O0=oGm+H`X=o$z$^UUd5F+=w=N zNsq$@PUC7P>HtMa#rZa(Rkhe++UNa-T}})ifvZ~kJ*jy=8OM%qdDjg|zf<%ec=6-9 zc2T$BBL~w7z1<5dvjL^O^Me_-Sm-3~4g&?XOdMu9g`dmhWrg`CRlivW2z`0O!^Wp7 zP5Rd37x>ePgK%^#S^A9T;-kl6Nx^C;A7wdl=$5S7Le7i9aF}MElkFXW*q~l3q zJm>+`t6QX6^0f#3vb=*jM$h%+goq3k@ZUkL`wzJ(%gDOT@{a_*KsAM*i#&f&72q#EF|I4^Xg|NaJqPUab+%f-75c+SlcG+hbv)j+YK~*3m3bn z(ELd7!KF7k_<(-3>DfHN_`zD?ov&4(*@?v9ZyS!>Hlz*ky!ejEK^9U1vjHry7s&?H zf=L1;+ZPlG+B@#!pcPd3Ax{N**%bW@{_Y%%=kbtd2HrY+?_lIp4vZi92l2D(_U~S& z{|$9KDBwnD1MH$HE@lmWY%9YRmKJDEYI4eb6=VH6#v?H0>in&sVFC{i>`&0?Sb;FM zh-Y#!E6iOCWZlNU%uFSIp%I5>z9F^Eiz2LA&fOzsnWi2$kEcYj z9g{3xX#lOz+{gvv%6E+e86Fq=Ooc>P&YHqr;OVmG>W*nYgeP8+mQ6>Slcp7X*`sHL%Sk2O_89+!SW|E6j*# z3TE+3*hC`Q=HK)aXkG6YQ?D1Pk=87iumCO-feKf;d#nza!NDw&+^1_(Q|WwO7e=$O z6?u}EznQn?86B%Q-_*A|qjwN{>?bTmuLMc!hk#tLdVLMdwl9zv7Shx@tN;~aHSs_W z4bh~;YhYd?FY$U5HvrJ-0|hTWbxF+I_?3ZQU}MJR4~^Nuq<55=&9_m7wtIu1b!7a3TQH`w-8MQG$(PORyRW;I)E zpi4BiQ-k+BI>=#sS^~~HuJJ}(fNy-&#Ot22ND#mCnG5tSIy~|o!4s2* z>MXqMRNPm+(SYsy!{Ye`w)Uv5s?N(>&pGl3pDjhQ8T z6B0%(lPPJd{!zBSk#g?^?ax91{Ci|sJd>(faxugd)4p7s`H~U8ZAu5C3a7`4{pTKZ zv#Pd={A$d~RI}r0{Al3HgG<%RT#X#|f_L*@`@ndDXP9~vCxLVukK}}9 zBm9Xzx+ZMcT*(R2YFf_?)7@~$>om_Xof zD#aE9ke~nazl0n;6Vhm@VZN-X_^BF;7#6P)!Pl&sZ)}ZQ3FDJ)dRwX+FU{i)2$vh0 z2Uy4*vPw~ZG$we-#oFf5ko~SkiM4T3^R%&}f*-~#&|veesgTmITcHI88(L9fe_Ks{ z@>2BbK|n1g=8Jn^X3l;31bT7;VJ?01VkQdazn;}-s&<+Lrq`aKMz>(RfbsM#w;f?v zR2lTsSLjgr6Fo>FSPXQ!9fJArZ}ibui~gmtVo)=0>HT-d%{uU)&VW+}vWtoA82@0( zpea{l{367$%$@zk2NnqL#w%0^Ty5=?^X3Nd9!-ptd6BRpZ+~tw3L%Zc^9_D2ER!`? zYT&o0$0C3I!w<%}(dr8?Xppu=*c`v#czgopzEsk{&~uMch9UT?@6sooPXuUER#uxf z>FU*?JT`|Ruya7_;h<+dxubu^$^jl3H9tX^rGE;ke?3K|+cV4TdiA2KT*nb3esJw4 zlvk&q>i?mUfl{}rwt>L7yZ>HB?mfyUk^yG#F8=ORTTA@1@AdaFlc#`__hrvQx(FQN zE^sqW4ps@THcQ?Fu60m67ca8jqV7IUHe4?+kk7kjyLxqqsB3YTO*uI1B2ZEDu@-J{ zc#ff|oz9}i2+m)(`My-_Qv`WOme?2ETbX^x{y2Z^17$p#9a;i2m>SYTz2XoVFxRIJ zG|kU837WfQiGjld&T#DKKv)0$;~WDV_9M8U%09$+74!cjSi+)jD1si|KAc@w%-%1o z-mmGYM^M;aoYDt{-_bs;t3ojIZfHoZc1uAZ@?g3p`O7?5!pIS!X~Zvb7Zq{W?OH2v zk}ax6_}KSv1fHL>((Cgs0?hw%pY0z!qobpPd(^I{ ztwgFSg#CaZ;cW-sH7wHymG8lEoNkU$aC&eKFUuEX>5JbBOq!NW!L+=8Sjf`G(7bYc zQbS{U7l`yjo~iyiY9^(-j4*0=A@u{l66eg9UUuEK#}CG1`C{7qq6P)bLmZtnoPs#B z?jkV9smcfOqTgL*6?uvgRvp92q_h>(Ie~n^ZX6hDT$&LhA!NMB=FcW_InWwg3z-<- z<)vY-{M5)ty>kxB|GH-HaWc^dUBh9%FOwe$46MnGH%UeFT`Wj~e~ssdpoXDv9F2ie)Bho8-49CbHC11}=J-`U*h}>+8?oRHE4Ez6(i{O z$JMiZg~cQ$+8&u#9;|*Ig<->1zdf#=P9Z!NwPZ_e&wp_uhgo|7N3^_VqtnG0)D0`J zVVe(teRyWJNBtF{jqvN_mB}{w;qOOJHsx;y6swMH+V|Iiy_+r(=B5vvd%q1cWM^gd z|789Pm0AayjQPK);|qr;+8l?x3KpNUCSy_ry_YKKeGqX@8y3HmQr@L1tcpB>L+?9% z^9s^T^?TjmBd1=9QvNuf@iDssIcjb!vYFl0dO*>7EnePC?eAd7JD-j z*0_ykPP?(di9@&QAZoGx=FcI7xmFUo5yFs9Q!bU%eG01P{T@3fM#Y~GAH==L%x($R zG}U2d&hH&Ab1Qr9lPv9uyn^_x-3rHWA-e(hnuD!W0lt)RK#7s30{mV|AO$68Xa!%2 zlZq)*JsUyo2mPuTIec`!0=t~N=C~FOF~>000V*)BA%{8*wR^m$L5U|VHf<$*=YaP;^zFM!KvsrDMD`zGZXdyk-v!6lb zA5x`ixF9Mp?y3G7YNJJqHKPI{d`$I~jEbysu!upY?qxH~zGeLzkRvvT>9Q-TU-vgRG4srC-e-jme#)*CoN zFMP})H0!s(&ya|y5x-lN1=(rj$#)suIy^Q;crbzqN93V2eGSXTeEq~h`OlMT&}|+M0%{s>D;E8laG=&9FEi}GyHPk)nF>4031@aGSDdK2HKzki;wmX-;5vgy=CnwYYQQhOMm9k+*Ec^Z0 zVm7d;t_3Pqu`WMx9z0-F{?>i&+^gqR&0K%Rl#_9rL1pe4I8G09T%~L5717F~+6hWJe$a>RLN>Cs>T_xIFe|@3)qx?-!QkOXu zWoRDyPTI~^L>|L`RE0oT*n{aZ&?LqI9SRaCpCQQW!>-H!y!vRu6DF!4n7%4%g?T3$@IQ zDY&s=1D*1I)9QJDL2S;3-}~d^mz^!Of=?ueJ)S2H6D~W*90iBu(r}LX%BfhEk5F zrwOUa^_@^xVQ1EIEUTg%m%qs8^p1%645R%Mmb7dvZdpd>FKTZ{neCG`yHfS*rSaEu ze_u_=ZgS#KK6b_rwa$^Uq0ApxZMx(uG3pd!D~qdz`QTOa@(DNwv{mL6yWSHxn{M3g z3yP!g{Q4+*ELY(eB@AnG+sk?5 z_n%(&D5$L*yFo;0|fve&3#@bP(3{S`qK%e4Nrz$Rv`hBE^A?4u!V`8p`>67^pfc zP8vcyrZs7G-U6ODd2>1EorYgR;L@-kS;Uhi{uY0wLah@yxY&{ZQUs?*YG84a5UF~~ zHa%2B4lGOW-vKjiG0Bqr(VO<+t(X1sN8y8Af!M)#I+*?nU9q%b$<;IWIZIoci!aX5 zsMO1bS6SxWCMBTvNlaBET{t~MTInNs-v)+~fZuFRR!&FE(;$oL476MK8#eI+(Ri8m zkM^S+%`pzCw&iRMX72qK{tn6s(fSa}Pp4Z1gCORUCd>V4NFvV;sL#OW9SMEQbmhOc z!##P;LnqH*3NrWu{n20jHjz_C;!G+9AEu@DfXTnAP)zWB4u-2Y^Abd+= zDn<;mNlP171o@gnB{5^nL(#KDaJOP;Fz+}P{FHZM@Fg-cB{wWBm&$bt9a7@^M{Et@ zAF=F-?94+CnI4k}L*l1@k`Fm#f$72!o8N$ghs@U@rq4(%JE5CCzGIyS%q88}*R1VV zllNj_8}gr7>3ot-xQ;fM*TKxh0nhsehQz%M+wmr%V?Q`eRi8DgE1<6TK~-t}IC6;VLSL5OwTrgXB7e5&A=wMpO|m8ukhWAi^6R zN(_c2d}h%cNE4}>n3q9xrjcSW$erp%@%kT20X``NJLSlylBfv8FHZkQy3Ju@?Ydpz0s0M4!Km7v#wz2sI=psCpaxwLN{)`o8F@lAKsVUnf8T>-Kx>n}PJ_Dvzd(t28 zJ43+*qw!tn90RHR!T6I_xg~ib8TQe_ndf7M&$%NS5+6K3E1ACKZRn3=fB$ZxGBx)C z+bMFkdfDmgHV9n8|5WArk{TibcpHvn6!9sd$u*Q{hgjfWDcw-Z?d&| zKd4O!*ArQpv~vF+MTz>mlj*Ch&J}hfpFkU4G}@#UFks`r5r6@F9B7V`h@xD0krrju zrO7Tv+cJ59e+gn*=*>zz?Dm4r%-s#W!b!!QcLZOPrZCT3c}|boZ8{dYPE(EU8zbD3 zolLAOp#w_Y^?ba|YSz1`l;Wg`gvZ05i~-B!Q{8%`eEGX#X)b^DsYU$HN^Mq-7*Yz8R@f6h!y3EzoUE3Cg`2whH#L`){k?c@Yv7VtL*W*AVS(JZ16?m?nxw^=3TDA{&T3bDqv zR;)ZNk)>PpW4Y))Fw0!f`u;}IGUflX07+a4!$)87eUr|FQzURalIU1~7D3$f&&by3 z18DK^-N7^!iaG3xX?m_>H{D~oLFwtb-GJQdLOOd(mel^bY8jQ?g+P5wtnhwSt<<#% zLetn1+`{l7o>bhT3Z?H-GB|^HWK!K&ou*(fwCLi4w00NG9WXH+T;#~vB&txxTsZo~ z->S5f-60vZ6z+3oKi5UqE6v9g{L2tmzE@|h5q1Q(31u~_U26U?o~Jvw`64=rjo}*t zuW!=I(dDC3cUYnWXaqk4&fc>=gYJH%j>nXLgizPI1>N@KDz(I|gm_Y2@w}G(CcOkF<*0p^Szp z+CBrYv(E+{82M%%)lW1g4wrc(rHbd8*EgV6nM>rw48g#FA`AD-T)YBA0T?k41HKRa zTUB7-4qpS3s|T_JV~5322$z0((R2qz0|VA@i*4u&2t0;cn1?m_jU_Zk1$}HF#rrCG z+60w6ZeAg^6C~&+!7z#qEQLo}{eP`02~pVnKO|kTW>{X~oQaD2l^u z%bAi2mfnHS1b zk{!|W{c;;0Z4vfGDzHNQ%%qsg4y6Jlf|O0SXvd+y?zHW;0!cnrs2@HrM|oeeVoFy_9c&k# z&Kx6cS8L8Qpp5OcED-Z%0(4VzDh{Lg>k-E9E59h!1cIV+fb@f{Z8z!5=Ayrb`pY3I zY4Kx!`yP%gQ&>B%BcA{02Drd|3{dQwCqwT4(-;d1}TLThpiF+SX_rPv$caZ!Bl?j z{*%56&8_mXXJC~O&A8}RjlDeiZ$WF6C1NAYEB;+C!u`c09D7OK@@?+5_xSW1NMKTMK)yyDR>>`j zi;V1_hosRn&6i%$8_d~yY@BqjP^}8GLMULJ?-9rf@>P$h2l+qtTJy36<2nzsGog$FcBW(>`>m5-v4d`Ivl?=i@g(8>jPflEX1IWY%pD3a4IeE*s`ZK7!yWexJ7<_O7eT2;HCz9mwa!&MYCE=Q%BYcQd zQV6)wc~-L!p+$ce&1^rB_`>L8PRQF&5+RO3c${gS9fJ3JqMr#0(OCsG4&4yp{q8b) zqTe5)?Pd^`=6M?$CiXmdcqr7oy_`%xH!1gtZx4QfP#|SH;-^2yuj4e(E|u*T$;smW z@5cVoohrm*=6l4JBMVN2-x)$%5!)H@(9{28>a2sJe51ZE1|c9RDYbN`i;@ycgVIRH z(hEo}N+}@%%d#xph?JDTQUVgv-L-Uy#8M(lEa0=h=bdNf{eu~X+1VMcIrlmDb3RN(`2m$e*G8Omn2zaF)&e#;?%elM_H=dAuH$ZO>_D z@l%pj?ZGTR#=iI${>f`bh-*RqwWTZ#@&pXuD;-o|4?!)dSnZ#5X+8wITS2UyhujjN zf9h>c+S=CMtMExyaVjr+AYm(+7U*ro0_a0Ldz8S74i3J`lFYIqt5qFqAqU$ zD(4|D;FUPCR5SijfeaGy`awEMPQk;z_6^gPd|%>ccQY7CCPFHJzw&3c--OQaaUwXU zNPj*jiAgyyW~1l5wmrTY*HPF@;qLF}NeplkRFlN%^IW7~Zl==r7gzP1dyh?0jvhW^ zJL}uq%=JBxnVDAu;8@cJ)btvD{|Ds}2C7NkAy({%vGd}dSOrHKK_7yEgAil$!cDR+ zJSGELGDN?-A%W&fJc!1pdb|6Icnr&P^acZYKJ2>G>KVGdUds+qo2~)fowzADrdH_Q zz6$jBm!dgJ3STm$uY^$71oIlB-`sDPWL^q{uqRZS{cs7OmnD`qaNBz~=D9opNf`XL z-Z;qpS-R64O$62nG4V5_F9Ox06o@SnOx5RX1VjC2!XFn>1L2SWYvc ziw3?@Fe@cxpTGaO-t&8;#b4%M*ZKaJepZXsSPMr|^&gMlrpbEkQjC_k|1|<bn+4qHwuKl(SB3m6}t1GhwxNPSoavC_??vu|>o#%%^C>5tj zho}{A04D?b?}f0eM`Xu@o_r$0pn+j-q}NFvHa_=ZCXf8>dSXx%KGs&#=WS+tY6@8H z5rsNaM)NSVZl|NiZo(kg*r3!X#f_Yw(oMegS91|WStWW6YT$nv?AM3-dJW9<989=v zICG?k&%q;Fw&fmmL=rif;=J*ZjQVr5#&H3YY!SnMvNtjl&Wu4PKjb$NAv|;R!4KjX z7}u z>+36K3*F^VBi^Y;2Z|GD6!Iul$w!s_yX#;2;>Ek3-_s`aQ< zIJ#rX{~_D&NO6B+>KVZon;m3So0ofvv`!5Y8RTN^MBBO}W;SCD=Ul?A$gsc;ecf=K7-er9(ShuV^1&mldKx*9jvoG&A|U&P=#)6Z2l)x z09JWGoEaLQEM9(xP8ME{diZ4Z!s_^So5n%6vC7mQdxstye-UB?zrNY41m(|ZD5ocU zv@^$0y(Zp7e)~x`LI0K$)pXa3td&Qr@pWO~s;|@b@bD$mNE~2JCX1Bb0$Gvqy#3ww zyF4PstQuG~9N|ptFxIiQ`MTwpDgyQLc_=a&hTfsqj2Q;-X9{nSDTH$d#ey$%r#@q} zza<`Qfmd5~IzbxE(WV0m3iW&UqA(owF<-;w!C?RxEGBu;=U|88jrDhdA!Ja19>Es?;%8*E}(KYZ={x$%NPRKs`NeRbrY<-YNW@B=lH2|02rD z%=Wm#Ej?T2rq9by_RG-+)wpK_p^X1(tafcA6VNC$THScYp{J=@q*wCSabezPbFxv( zheV-a6Y4SaJFk=1LR=G{`-y%DayTFlRFfDu%y73?W<$VCVHycoYSO^${% z6Q6U1xcbI~9dZ zt+9D0vG&x|Y&u}%jU}4<67sCZ;ru-fxA2$C9sN|zOq=jrsu~Viv6|MS!Ps=8v9q{myAn!KU> z7EV4zQ(#AiaG_7qU}pwIv?KJv-HBswXm9A8iNh-kpV^FrSSHx3SN+b)gV?3eFzz*? zxk!!*x3}wphQAw2owYa^vv$5x57M#JRWdD+e%o>fso9A%_=-31nEH6oZT{ZxF(>tD zhJ8U3-o_N&Ti7jQXASaF@3GNW)uV89zE8yo1V@xT$x6_5GDpRt)l*&n=H&066e%sLW176-dk20>u)KP<7|`hUonT_g$8JP#V(HAu=3^hK<5QT z^@PeY6KpT&;lNPw)9VQ3tq>-48B>UOs-P zfkuhQ3p^SPDrU8Dd+?V~4zM@T;XKmo*?BP>a-*}o^L0W@x68%cq*Ur{cP~a^Z{@za zdZbxoNKw2QrCr=_tos;f{&7rl&Z#)Cq4eL!uK9e@YVlR&FTYw-Sn8r{D$So-pZ1@s zi2jIx=6pKcsQz@;(w4E_1~&5`UwruX-x;qSd`yGEpPjMlE?I&FqpB*M|o) zhEMeE)kLHJO(u8*p<1=Dl@<-W%qjTx1P%xumo+*MD4w0*z zJYrbi2;*A>Tb!}0Y{;f0!%0R#+h&*RAEpR$YOb!_%lLxSO^U(N4O@#;Bb=*>egCeX z&i(11JMF={VI{`5{ZSqd{R!(cF&Q8Klkw|qypG{``@jq6!1?l_dd@~FnIh9ySpod1 z{opB9{^5`y;}rL%k?ALeFk=WrE!2P`WT|?zMx*Lqc7k)HVacdPO{${jcN_qT3c{_f z{>DXDDzif{PhJa zq?d`Yl*yHXyXkZkb&4Twc5+Z|qX*=!x~@S_Y-Gw&O7>mNqOE|pWyD)KGy?Xq= zqYGag)>SXlq~LJyZ6NDVyp3p#hC*gGqsZpkYFcu29&lCZc-O%Zw7lF?Z$h0EoTU^oK+3I`X1$3Nmtj z9QN4vZ^=H~vo@~h2%;Ccebk>CH*P>WgwA+-*=%RV!tpXdi+jioNZuvI4EGGk&{-y* z{IN?ub&!WTKLZ9RIKBs>d8gSO+x?0l)*R+VpQ2{6%knh_=Y4$z22&qo>_($4Pl-hs z^dU;9r{9>Y9D^PQ35qQ+Ac89iJ?tmTwv~;?DJ7vlM0g7!ZOJ#OvklTA>T7Cbrj_CI z?SOX2MS8_S#(9RVP(=x?{%*XT0>H!qSj;mWm2u8rRJI8K{t{$ zKSSNavO_rgXQPh`A`lecWT-+t^|0^M{wut+sO6?UO#$%KeMR0*(Wg9nM!bI6WlRRm9msew5Hr4n;28F(+hro{-5&BFyX*@FK3YwLd=` zlIM+nY{lu4>ZN%iH5uG7N@Z{G^^^>zi2YihYxn>r#r^6&3E6{;f0iKh-_JLi6yT6; zq}UL$D#=pFrGWdFn9c*;x<8Ekj?&N1rOUxs)Mg9Ia&*3_woj5PKdZ#S(AWp=05S>w z(Qgp)>(0IZSoBdRE2kC=!X?MebmMvF{1N1fc~kDk6`zi2~nTGy}#32_hXK;o;_8fzSbydt~So$xq#zeo@ukz zrFR=9Y|F1eb+GvqM8ILTqM|lCKGDr*Lwb`!PzQkBO&1zu_iuEMUA_)w9d1ewS3U@2nLw&1~fwQiCEcI6JEQ z+i#WEsoY7bck4%xs65=@^yN>0E;^YhG_dsFkJ2xY2_-=w;(qPj=Oa1WpI#2|P!yZCPydREx}l^ur>|ztc!(HKeQ-Nop_61HCXhS{@6iRG zhvg#kHfx>s8Bv>}@4=p~I^)?;x?81}%?k+5eiJfQME;GVPzk zwRt8&{AkQ2FSz}9)t*Y%{A`vOb(wJE@%iMWtN?t^akZKQUp(C#63L-RR-*Ga41+Fa z+F1OZOt}stjkaR;{fQNdeOgwSO ziVn#7`c`qAzR6*2#5T4NQ}QwZTiCpo&&83oz`dC`+3fj(M8CeWSj2PbJ;^mmQkq_Ux2(1pq53o^-maOG^-SX)p}Ss`q*xTnB+HCYTBwUWCYr$h(nojhd; zyKz$T7*c0L*jGE0w-p5l{8Nj2oZx;Hh50_jX7@g{$3<`aDqZh4Wi70U3Vh0eJJ0>8 zo{Vbez(a6Okke&`jl^$JqAxZ#L9(5QNEqcXPY&YQUya|a&l7TdW*`?ehTy;6ck3~l zkOG3?h9MUb%ci?fq~vcGJ>Xl^QDU$9U)Xh27(T$B^JclVbwOwbe!MI3j`4O9Bs7pK zcx+mjih69{(3!9-9{240l2GwXMQu@Rt^c}W=7~vL!$;@!&>ysI$;Fg<%9{oazx&^= z%2H#h+7?vYu-WHnr4CtFOrL6cbY6MKZq*8O*KRc$Z+*Vuzxh{N&1g^?&ch+ua3m<^ zw*>g-aAX;p)j!Fn_e7bRIDwKO-IZK7iy(&1;FCuxXH71MxR$*(>j7N```P<(eQiCu zf=CZ7!W6eJLPGECvQ9ctmPB8bkmJc#s8$fxah?IJUh`KDAjX&Cd9ETxu>=;^HQW+r4T3qwSF^ z+{HUF$l~wQXFvODEz~3_)BCp*OD;*%SNN;W%HFDdPX+%KZXps33bpnYt0h%t&yxFh zYDc;tGpty=cNRRQ@8LCfddV{M!J)0{?CJBO)Er6T)TZe=nA5p`+KCaiSv;`#iv=3T zX47_k{8GLPZC?BcAlBE!zc;GTCd7-U5YZn0_5Y-jiyH*~pB5mlok*;fXrJ169FzR# zjoXxt|C!U{QcU9I=Px_wbYxQ3JJrsoX}kFdrKp75k)`~#=j%X0S@#9&;?9nmye8U- z=^r~Q2?Fd^`_mumr9u^aO8}CCoQ?x;d&@aJXad6W{VjW`yZjk|$N8nz zoOo0FqMXA9)Q~q9C!4i$7ta{dHJ<1Vr2<Gu*vNMM6D~8v?CXj)*eky%Rz8=m436@AKjgm(i~QC|5mfilC`y(ob~&ehHp{FDn{DRT)P} z$o8oeL2e5)b#nKRLgtgMIY?UKRmj@ObR4??n!=NlS-5Ky%(lFF6pU|y-%AQ!PDw~=6C#++k8w!C*6ueE0Jiv6;lgY=fHr6O23dErF=P(4Jr z&h!z*&HE7M&WD*Yo{E8QoVtwBuTKRnAC2|r`a(i!m;Mom!GD34-#_wn{sg+efPL&w z*rk$WA|Ge!GqO(PKZzyN+|-&^lGTyBBecY+02wT8jX$v`|jbw+4tE zUu$1lf=S(G*g_Bh9L4$3YJ0%Bh$Bk86R`$JBd_0^)BN7w-M#ay5LVsU2P^JX7q*I| z-ymw&-Dl;T?|bBn=pJNkm3k=EXSW*#PQt_xl&9;4&4BBpue~a58|1b=bCt_=Q6J$CM(AqdKOXum zncU-RwopAP;=tncY23#2)I9x=1xyDw4M zkcEiveCOhLZ(Ulba0Zv`E;dFQxlDxjN?kRnvzS~TItk*~whlZ64SCmhQ-Nm}@tiNFtG8c( zC-t0w%whhi=!ebvl?7uV+fGMSn1c`nxw3{g9IF;*j-jN2E;|VB)vQ|PmO%xvDZsA; zwtyjpx239|@+`EOkvGLtwQpu$&5A50%dmuyk@gjytcMK9~?pU*yHUcL`Sp9Lt-dxg-}WYCJsl7P*~I z(yH4fQ`es-P(DjTZ_Zui{{nSH11}xtFhCh za00Yi3OP`wrt%%C*=lh?i|29CU1I5fYp;(~cuX3ShxnVqMQZsa|6E&wE!<}Eq(yczE44_p*%x(`aOxSR71}hLO3Z|{_02LDyLFwb02#Gd0i!GW%}ta zT^D>DZk&1Ezxo=`5Rj51ox_&WrPz4d+y@bgx?2XOzHMf2p&WG!F-Yq36CisMl>b$o zE>;!mbY~s*aPIs**p_|q38zbo+f!q6ZLJ=+@23>G{t1clr*RTEPBG5BTYnLhhfc%F z#ajeWVNVZlh-biSAzJX$kILA|pA>>%_%pF z_(bTxGR_GlxU6R>&A9P8InOAPSafwTOGsY&hM!)`Q!Q*czq$h`?3kLWRQ3;$=G9Dj z=88efyS<}Z=zPfZB$AYcmS5(s7WdTX^@|YxW>@f7hG1*+x0(Ur^b?44m1~b=Mnbz? zhUDpD2E4|JcK%VH%fOxFy29tLZoyDtv4l!p#RZ7FrosJ-frg#6*LfjJ6TJFl5I)Ht8x>9x>TbS&7NW(^8RGpKDlp*pseS)Ao-ke5ZU7-{#a&z_Nw%uht;78#d)(d zS(8#aWtgdz{KCjDuAX_00=|o1#&G4JE=>ntK^Ih2v(Jb7O|kZ`TRaWWbmoPE_xN0a z+1uB-4`1R<8@Bx%Dy665M@^tKN9SuqG(D;dcj_HeX?E*`plK-|)}qGB{Qb`$rZUuM z(K-Ttn1}EZiN@$#y#dNxhXk}2lJT9d2r55rdv}Tv-$lrYf~99eqK{R0gCLMVvPqEG zPedW?bBgX12+&2f?Wi7gUjdt-=4fbnpY!_j5mh=yUx%Ktz0CRpYzgn4&CFJ%tA_uw zQ5HPd2G4O`oyf+p5xN|Ei<~YU$tq49rT05|4#nA>jt;o-MgUOn#6wvtAS1&HLi0R} z^IOKgzvDC^Tp)~7-M89b8_En{qi-h%tRg_^`3~1y`)WcRE{^|V<;i*Anm=B9RzI=1 z`)we;g-oh;h@&5KdHG%61^4-LEA`x+kHjACMH>wf{`3p4PWd`(vhqDW=VHm70%8N6 z@4t%}tdB!wzI(8M>{$ z@h#jhbj}cd{>aQk?i5fTOYxoS@EOh}Q&oHrgE$_?{lzu)6Y>W2CMMzOZ~)X(Ugmd5 zLGxx}8uTWUaCizL8b+oflDASm+Ws{OF2H>Ey8r!${{-)x*Tw9eUXZJX{Quxy()>wy z=Vl5bpW_qf_GL>Mp2XcLDXNh1i4S&*tPc)s4QEQ69EY9=w)lx1^$f?KgkdT;-{DfM zNj_=vMvQJhi2opW-dg2njPB8UIh0npyj}c3eR*(&2!2yu&(qU(fBVL=Y&<^ZW5d^QLc;aVt{P)uo~+`xEVVST*d;Q*?qH428biWDre>J=Lks9#8xP&THy7D=iDd}cw zfI{+~VY$Mmmz=NlxTxW31!h8NbNAI)FW& z?57sKs*l{Y!pasfme=I=R!833j27LEJV!`nM}={2YJB?3qzuxGRpXCJ`XLgO?NE%` zFHURX7_HdZJ9a=b5uzPsgYrqI;`K~`42=7s4;nCrwuU;*N&4MdleNRpGQ+ALJahl7 zBZK(?+Hp-UtC?ou`XKgN(z4-D(#Hu$tJyG!$FY9-fAT?JR1G{z0*$W>T@slV(w^6O zt-d$#4VkWI4K*V@kJ1>(o^p_v335|CG)@j)%R43#OR+SqoEESr2yO~~= z_b!UAw;YX@ACo%`%KP*SkM_$F`uZh|tX(2Csd5{Z&W<*6o}yIz3=-=?bc0P%#O*AR z13ZpW_4VETp)`VQH77NCs}~oeiydHk-ObH^S9%%Wo+T}-u(#|I3ep34P=j{!ROjcP zA9`nWU#!!!cF2`%tuQ!J)<%g{t<<*yorm$19 z+@D=x)R`O>pE{y44qiDqcQ9&?Di%1Wy+A0*O7?`|}j8 zl#G-%DdIh`Uk#+`B4pm=ae@&HvqA{-&QP*V>E!f!S~^CP5kEC3uZVaol>cl z`C$FTha}ygnSm{bdVG$S`3jl5xL}(5PtvHMQW7QM6gg z7dOyCM|a?mBwOV1mt4S`Z#Bk}i!vr(=+!zs$7B*vHCw(bpR57yuPkUHXdv==ZyyZ_ zMNw4jYkZ$LD)OyMQ;kkNx2Dc*GpRRok18AahB~X$uvG)*W&$kt4CLn9M86d{Nj)v2 z8{1lVRKr=Lspu4|y_g%CocnodDv5Qf%_t`E?oypz0naSFfdGw zb?w<;Gd=nU&-L2|yXygm211ikCNSZ|ifbS--j;Bn!rc_BSOBwQLo^*fy2Bntl5ADH z4NJzOuar=ak-FYgscR+L^!d-xSO2k<|AdCBLO=QC+^+tfGcQbS&nZACFeVg+$%+j)@1eI!fy-X7c1XAWw?{s0sS$cMoYdIk?bg8fpM~!D=k1)R#@2JDp8=U zr}9fAvjRarHPRj%PKHifdx3Q5qcTUS`jjbIHv?lO#LFBsLq$)pQb3Dy zN$+&x2)!$iH7XBCZT05-h#Ddo;Q8MFsM3UG(51nE7x!WI;%gDCnz;SC2o@~ep^Amm zk<2QtPdpAD|0CNKP?x!!Y+&4I4IO&Gr)b&*6^Gp0Eeh5~nHe>3^di<$U=3iXkcqAF z-bbxihPdjtSU+>G^WmEhpm}SWnOaqW&(Dg@(SfP9#%2AN7Imr$#RO^{&g_Slr)5y+ zw`c06CeUo{UEfocv=A}W#JkQBKYS(xBP?7o7N>~cKZjXQ8HebGUx}adi_2weyi&yB zpV?$|PJf(KdvH@c2ye>%4u=1^2e`&kTNZ}O%Ao5LlHhQ}Ob0lrsZUE|uva1zC6T&b zk~)!oURJJ&mg=bT4Q{^Yr%*8dd)722lf2q4A;I0?ul%EGjkclMtJhQNRSlV9X7@#9#S@P_ z*XwU!StH}q#8LL^Z-k(t#ovf85tT|*byOIpdl~XO%<=MNeJWo<(2)#3%tlfLEap^c z3XvhI&#oCp@Vf-Olw-g6kiK~T?tp-xR+gG{kYAgic|T-G=IjB;Vr&)fDPVp_5?73O zU6WGjSt>D&?pOsbtr|N5B{=&K=l>k!V=)w(k*h&cycRv6=-w3@Q}s!Z+n(LAzDMx1 z4~nCUB5-C%5Z@K&!Gi#;1o-fZ$e?@i|A7D}L4!ly$2D$}-7rc^F#T7IumFB59E4S2 zChq~&_4Y*&oqm92-)Uq5H2})1_}>RhvDepyZQPds*w z-C#QBuFywEMCnV!HHpI>2bi zUB{yZwYOBD2c}f!h18^MLovu!yu)Zh|H_960QZ-szC`l6`lydEO1_qG&;=D=5Rr+( zVPFUid|*2rLWx!c@!)>b;$@E^#6)b0{gwT!YpU4BM8BBpcY2Ap^Ka-L2dJpP{5mPc zd==nI=fdo$(QCFXO#r+TGIzmA;VqZAZzRiM@jOH7t`=Q=kFbPA9XQ zjj1JOJ9ugyI07+tDq)@*Z%)&3`oq6#NAImLvLuvUAo($y+&h0zZ)ZR1E)aF177y{! z0{LRs`F|(+`#CuxzC2j#>C|F^V|Z7-yCkEBsBTuiyBU`}!hdgNR%7(A^|2usRwf*G zm=p{D8zl*b{`TI?#BY(98lB$*`P;MxW5z@mU(i_{2mVyTAr%Q9T z(5m92uAaa4rlaQ5^e_~C!cQh(LHi00imbKl(Cyg7^d>2$7{YbGS1m0^`GXzRt;k^D zidwa(dpwk0Pj3fpGCYh+zRk@K1}f)v{>ZzR5bj1Qc^=cH*6S}3^ye-1sR~${HsB^u zICbJW4*#E8g4w3aj?{Tua<}m}6a(!zOpYx6Rb5A+>^a&9Tx))oNL>zO65-7vS13zD zu-Eb@jR3BJ$N_aoa3rxJe87bBdIjK%xP%`U09xSh=ExKMO7c|_6-v;YlcpdMls^OZ z?F+A^J9KjHF9jAmIcg4^s%i!Ny$Yj`z;04}W|4N5?E@=YqByuM{zmZyq{$UWP74F7^|rg&@8HQ zh^1DXZD1g{@V$=@4{YTyuPx|oerSUp%@*FTpvufP^Rq9+eA7W!_Xg^R61R$sfw%;7 znEr8Ct63CGf*ef2~ zvfLUVyZ@FP_Bc#*30bJv5%Etek)x;TD?1f=*J1nvnV0J=2>9HCf$GK_H!0Yw0YL4X zX-c!RK{viF^^llOyatt$7U-SL{CKMpLxP|0Rr8sFjj({|@vks7Lws>nb6?DS?MrF0 zqh;5kpkkZ)q7c6!w0KzvJ&^_b&$K7K*C?s^#pw=3H=qnxJRlX%5gEUY^WI(82V{g~ z4noRh{Au7L!w|)(>-*0`QS4k2G|U_lxSq>__;L1uX{6x3@i81sQD8f;S8!z;G0duJ)rWd{Y zLOLHbNI;Js_Cb;B0mbnT76$EmaeR9d%m@cwq|;kQ(x9b@xxd)Y&oOfUVbi>VaU>SL zrEOqifGWtic48*)L=Y~Inh(+ue6PZMNAH(w$@`a8Q+xkacIZFXuQ1$D%LMb$UQL$i zS}_Po?A3@xa(@LO7Lw7m?t1pJ-8G-gzQ8e+$#;yPQ1zL?eg)ZI7XADj)*{{|q2BJ8 znXP1FXPCU;B0V#+C(?-t?a(;-HD-nst}pDaV` zYB5%Nl^BCrb%zJAR~fBgbPC~Vx;c<=Sn@t0+dG~h!AfIZCG;A&JzoN?X!pH8KjK|4 zXw(OQlgS8&*y>@^GFEFA%*nLpq$LkG0cOyE>apNUzg}~b$OhPZ5(S-w9&P{HuI+Mq zDQL^@vm-3PESZ+S(AR9+M)5A}0yjo;4=#IWD^_5{`YPtt4%)Gq-K_hq)89;1&^WOj;_s8FJ%B?!IsdmQv+|MDAd zaIbl_@ZU$*P|{G3*{wF;g})KTbfFcmAX{5YLZOcq%?p>h$^g{<9^Q#Z4{HJm=88b|VW=O`Q{6?EWmR75{;@I} z_y)wYpiMZEtXg&5gO|effSUCARH1{l(@bptb*#=$V_|jTmW6OJv?-mKZgBH z$Ml+G+C=)38ch|C}PtI>c^WHxGh{I>3TF{YF=evtPqQ?}ES`Go4tMf?4Blt=Chh4x|FilJUV!Fr|X@y)~lF&NioL<=H#AS6J zHgT)M5)19F?QZZs0jExs4Ptw6_BZQGvK-dV&OYC`M0uSQNP6&fK3c^7Eec_VAMf09 z-dx4Be-^Cj#c%)I0 z^=AZ%*`Pq5k5;Q_ax%`a+iTaQVm^^;*>mWSOQ`5|Y_E{L^Z|Yz_F_=2s=w>CMV1WL zC~eNl{;W+8*;9rf1*NJnSMK6!HdfMd4bXgiC6WEm`U5+5#}+1kr=`&@*;6G^BN5Ni zL`;cHojiS0MM>k7N#6fy0nFbxe%k1bdhqDs(8WKAVP>~@aslTmuf~MVA`p2uODjOs zQ>NqLw7COM?K^ny>T>~O(x?8jpsKE3b{oi>a*XMfJLNEX{z$%cQoT(Y- z(i{!EcpJD==S1HWt%Npp(ZjZgo#Txl;n^5%CvF@u2QK0x?&9=_p6*ueQnoU3TRTV( zYM}z%50MOSz!1y0+Vcl`*((0=(MsFCd0qEF&28oQ-i-i<7_^iw7OM zu%~@imve)bZMokLxP47#Z}(|P-T$>F0j_t}HZLB?caHTBusS6}5=E3b7Sn?;CCWhE z<0;PT=9X-k84|mIq|cCx%46X*3v%o|hkK5QwYAO5#%P;J{L2_Oj)iOxNdBLe@nwA3 z3GySRg_JbUa6VM*a_zP=ZH@kNs zxY}A=o$fNLP^WITYa~~L-CY*O56O|a9@CStz;Dwg*;e{CrC;(m%SaxVjxmJIujkcG zdW0GZS)rKsi;j0WeDlk;%}3bfhxdA-O`eFNkE@(sJmr#NWTtRxv@coty?xo0Ug8&OmqJ6d-*st$Q&nP6FhK z!sp2Qi}4x?TY5%EA~ww^wn}65_WrewwHNQ%VvhIs4>(Ce3k{cR{!=@ijeo{?0=ueK zT?&4hoKe-mTv8CG-^eHsRQ*wYR6yRW6EIu7)mH5TV(QC9nvIdrAJxY_&PfzUQ%wEX zm-Ma@A(AI9B+MVAm-I57ET!#Rh~CxG9T-*YvK8E)!Pqq7Gl821na$8&WCNG0?f#=x zl7Y?1-+d*3dj{@02UkRm`*iTxX=S5Aq!^8Hj%%Ujm*f!=x>DK5bIp9@hp?bdg3hNN ziun2Pf<}>YF01p@ZXgvmHn^d|lnOWETIGjPR!(IysB8TAE%`tI+!qIVwy9j-!elva zEk_u)tfQ|x$DucBG05}t3rA7p;z!6%KDr?YxI?+6*Rg{E`?qD14bHEblZcK`80bAY zS_PM`HOMRYBvk58sCh6OAKHt1;bQIOTj;YyczrE!!+*B~jp`H+6a1*G2AmG?c~+(T zis3ElQ_aZFb3@yLi=tAsN9qK_pSVh*`^?9-MKCDgRY1->svu|{f zb`bYg|KN)J|QtC3>-gZGBNOHD#X zMiqK&k*HU1^IoJ}X$|I~_(|TWRq!_kW~oN@?#f+e8+9aWMmImANPf)gt-b6sWnB%A z==~O!!Mzfv3Cz-) z###EpD?Wbrij{z-yM`{MIziJ6yewIiWOac(5eoz&46rStjWRZC?w|zSEN=_`VuJdT zA@7uWX+FpIrvh^%2{)1!o0|`;1_%d3O(P&iC{Z^p;5^9ak%{;JYAKxVuA(*O+OO}tca(D(*R3;G072q|Bav$27Tko~cN|1B$N~LxZ3mxy0Qbj5o<6BuvnAv;c|$_^ zp6U@*aqGf6lvb?4Xl+`#U`4Wrm*JR<+(yEW8Q?3K_rV4g}#T8FQCy zhgw2vzq9UqjUkb%;=9e`;)EP|+cA4V_E%CC@Wl+4{U*5G?I-!WO)T{Rm!Qk)C95(t zyN0J^iIncb0Lo9b{i(glJxdLbJ6zV+v!>vXk!5h$x5t`4i#1EE6<*Qf`k~bFjn6;7 z|LR_4)vms7CELCYi?_PEP(tv(d8vzN1FSLix;hh^W~Gcj>;o^3OiCf% zg({TGGSU{`M(@s7mpShb!+5HUM2YTm7a&5uI-WZ)liN0!sQGSRzpI8_uW^kG5Wd9n z;klgc{Z9!|tFjB(kXfQXCyjZ`6cV@oM@d2L>mjdY`Oy{RxMO133Oy#ysQxym=ZhBSmfG~`r~7&!YCZr}d&V6lGD2?PkFSo^sa zDyghTEkA|eXTtfJ>T-7FRDo*8dPo7y%Zm4-QByVpcQp%uR(;;Qc|w&P_qGD%b4H!q zRSC-%^SBpnTf$4DSqUpA?Z|hWviW4!|M0A?_i5H~xo9`aHtV&LygyrhCONPvZOdiE z80044OoI|{9pm1O7(~kseHxwL3!Dg?YjJ43vL{qj$9Qt`D#tZ$dEKZX@(g z!0uWN`sJaTaDfsx0NtVA4M^L)zF1SS76qfW=!*-}bmn}J3wC7MUcUH88 z&^I>`Qk_Tl!yv6DR&2qlEqF!O{gDA6UNqfE4krE9@IN%2byU>P`?o*RDh(1#FWo5} z(jeVP$I?r8N{6s?rvlOlEU{q{=F=&HGrgSS<#fO+YY+znrBw+xel8zUkPP8wB ziCfXK)j!O)Mxod48E}$~pnp=vzLyE_6DHcqs`LM_x{cGR0>?bbj9TTMuPLL~#xu68 z-RM-Kxg&}lDY{vJvi}AiD39VVK%^9BEq7pldtCijmB!>#BPb<|^DP5S+AY?XU2~sZ zD=`r2_qPw=DsN~mahC+``&!({V+TS%8Jfp|^)!(b8UN5p$bX;`=}#ikfC{jWo&Vf_ z-`slM4GfbX94w9lQ#00AwLXJiL9v^RLP2||WeA0(Goe~wRW*?qLD1)*l$4aV-mS8y z1zMnNdXu#gP`@tc>;UXa#;(;EW35u@S)qtc*Q)0UZ$)B=#KPIsHlIMKP%~XA0r|84=%)dH(#wsZhcBbPjw7*9K{+w zSsHcpIBm)~Ty`A)X#-nSnwwjX5r?^v%Q6Iqe^sP62jKwdrb>d<+7eR@efu*e*x*U5 zo!<0!zvmPwsF2Q?ENKUJ^-g|s+YI^w%dKs1MGEL66o4cv z?6xvg0060+<4fsG}&}pkwa;w`fKF51T3dx)fPwR zJuN7bUGbJZM3Qpd{rj_<7UZGp*xlMQHy!^S@=(pd$6N~HhL=so6O8gVeFD7$16_j) zgbdxVsXm#Et3ZeR`n#k)KUq0vIe|7^qtCGfU%fk`XTB4EjvA{TkK>ir+8kazE#ZVy z^v+?`3d0B7=tNvRyyEXZMBS%NQ}h2`ZBFIxPv6gd9ip8bO7>2nw0walOXNILi2c3tct*u#^f&B|K?)|#$Ibw`Ov%{y)bpfs$NL3TX$|O-6=G9&D}zsP ziGn5JbfF~WbAKCFPw-IQrDi(+QMiMDd_Q0C@~+%uuu50XUb{#E-k#R9;?Uk#9GYnG z?Q|oym`XggBt-;19#wR&G-X|e@pRuc?{LjVTTSg(g9Y;kZuXkz<8~~DrH=uJ)th&c z)%_zMp;POthtMIchK3vnC*N_}z`e%hB_SHXt82VAO^MZ5 zA(8AE#Qd{_9%p;P^ZY-J)O_xPv{CxwY^AWDhL_3^HJQcB1+4AvSlZtCBnu-t58GWs zxC3p*vp-qPE6(z(A1>xLl1M`%x0ji#d3Qa357#5EUBS<%y%`p(kAg-)F#*25Q4d_I z`E)mUq@^p}`RPHI|4op^kSzTTnW(hg_GrJ8t!{a`xY@cG`Uc6!=!z@uLwJ55f9cNN%l7y2AB>Y1rAiRW>LiVAkEf3M#@Qx5ZocKmvcw5oX$at5r zR}qD24|PsoU;if3AdfKdW#u{d62Ja^w^vg!EixxgjEAsqn1r1wd-}{S(|1t*sYHt5 z&83{pnQ=vlPmn()2<5ofG%!0bJqgvw3dJdRe0|@D<5ztbfjdODuuM_4K#&%epne`` z)vzoTz%;X%*PsP{qYJOeBu+f;;ZevkM~Z$q%?-+|4;z*z->>b(k!N6bzs zsUC5`)N}UfPgNN@Xsookz=)jP8C=Z#`*UqUk!IU!S%8B`Q1g!jWn8Yr$X8xg2`{xN ztGty*4foAr3-4_eduReH2ftj5_&iil8OSh;un;Se&f&VMj2+$gKuZ*0 zj9_$0$O(Pm#~T!X^fes&?^PNq^MP^oMToFbjC{9}ddVD=%A>ih*?fCBBAker&nu{S zyfUD6)8#=VfoWEfe&L zBIBV`=2OP;=t!(UJ}W&Gn`N(n6Y5<8_=9jT9wBK}UQx z+-HM`H~*js`Hf`p(@hTI4kd-$pC9FX;k?(D1p%M-04=~T&OIZ;6Ap2ImT zr)Y{j1VLQ}W#@?`3Y)&dQAeUxBy!c`l5GL0KK@HUUcXz_R=W62WDPA@fuzVx7b1S~ zR@w=@coKPsdsoT>ST5q+uX|mEsQpeR@B!B(-!^lDsVzkE(gX*4I*N2ezpz*xD>jt+ zh*HFJcqO^Cv?pw)cx?31$HU|J&@(OW>e=X4+cug@18K(h7ZXWI`+Xh=F8`K~(9P17 zh5)l+Yg$*&YOwg(>aT-vC3a4=&TR?L-RDWu)g2hL?kD|N4dT8+NY{hRfm7|tenvJVJ z*Ft`z5wFrIDTiXVm*H+~vUcwS9#(b>g!}OGa+?qW*6E9YF5rs6Mu0prd*Q13PXV{8 zs9(5Hhn!7OF|*NtP558w5{_a2^&kL1VmZAQJYoBb3CzH{Zcb5#S|LGC!+EF%S~=9V znKy{VilXX_h{)erm>B2(Qi3%n#-0O5|C71aIv@V``q}ILKM0Xl1uSsg9z>7?Ua8e& zykA@wgQJ3f&vtG$L>Fo*FWTRd_VYLs0(G<2U#_U?z0D_?xt!;maiANEqulvK(&|>j zGKY2+kDyZH@mZ0~Stg-Q%dCBxqTxC>7!|mt1yhakH8(~OGI%Qux@9#hDk_L43auOd)M7>tC2vZy-|a=u&d{g_LqC4N61cZF)`S)D0%f7q zlNAhMZxXFy=@LdPtWf{f|FL@o1t@k81+y++x9@{P?mfwK5l*{hde$Puh=L9eh$yYo zW;@OPn;1EcZmlSinE~iaotP1oRA9!ZM!X0H$glg^zrWG8V`12$rCdf1y+BJjtV9Zq zYCTw81slh+j{4g2;39H=$d-RGlcuES$d& z6LbtJ(c1qiu)xa*sn>=t7)LS6_cHD>LDNPyUU?Grf#fxj;^nAk)?g9-TLttrcRxcg*s z$PwQHjQ5jpfx=LKa#xAjpdr8JWAMZSD0!X90xjPu>ga3+y zmjN}i>aV%BD`*vaonrh8BBuCW1*@geGz~G3%&pRHFAG>HIQg^iyL;^hN&Co`U)|U+ z76rNZ2*GyjTR_9?Yzu{Hz5Ma4y!n=bUBm@c$3T1sDH=0Jfi$UdLo<86`>P{IygZHF|@(&^6qxHS{lV<)d*2^FGPoEgBWQ?1$EA5+Y zB{(Z(r{GgAWq(JfoO>6XUiJ5s{a_n4Y%ykk&Q*#kATUR)-y+@yb_7LR5zSRMZFX1$ z6TZ~J3qF6VoG?nF0x2|9a@GpoVSqxa7EBgSIXd_9R3>OY;Hyb+6ql|@Q;E|t*A^yT zX*xdwpB;>yA}Zn?{N)rqS!@=`o7__+whr^Zr zN%$<31Y_0xfWW)giQ&9;=_!}k;&=C4 zeC+4-{mVnhf)ze=*63GMA&-qTS2uRA!}FGHB;Lt;C9}JhGxP#WcBYrzt;$w?%o+?y zRiNYOj#DfIuP8rBxqhl3g){r7Sm9)?69f_n36)BMc2lEsKltCo*nS&X)P7w=^~v!7 zrBw>MqjUl5!;OfJ7IrP400e0d4#eA6g+D>7?Km^bDQz%R)pU!GD~;M{QBgJm_aBWy z-B9p~Z6w{oa-bq5p22dPhfsPoIGaa6Bo0+AsS)zZd8FZK!`ob03O*-F#tH*IB%Tfq zm4w~itZI<6=Nl4Jz&J5F{Yi%)oJMf!d8_>cLr1|L<}mOm>^Fxi?}XtOn(V+#Z4DCY zKnZ4)QK#{D93`ZtxH?qNP7e#p_Lh+&3awFvo~@eK8yYvd;KVq=8W>Qb*%+^8TQ=-R zJK0iNg&=X5>o3PSCdm5R*Q92z>TnUFt0Vc$Y-uB`0bY%51pmZ?MjePt4>pw)4?FeY z8RWDHb(6iaROi&N+FM4&rr2OWvjULPw6qR2P5T_HNxUMyrHU{}q6h08gE-vu{ig$9 z(g;?X#zzDsIGUWYYRgV8k;`g(mzp2yz~4uZfH0-tPS=}|`sp^a2q5E;`-li6+gARF zFsKVzGd3;fhofbn(jeFc;8ScoXux0sY`XV1$XUe~271=#iZq@{351mSl`I5ASBBy7 znCXhXV5MD!SPWRZvg5%!Ch0OCOj`Im=}^@0{H@~%CTA115!K#zb$+Rb^?@XR*cfI6 zjb9OO0Sz{ejrO?N29!t|5Bw>0Y|q3VyYuoWIA%Lu`Oo0=cBi-JtY3&)g6-L>5gp&& zp3_&Tk+FuE&@!gb%BZya-r1{vViezAHouA~Vd2|h3OY=1-V8Ddaj~mI4T{7Z^*4SZ z__t{s8He3n`RQde9-y!^PwAiqLsW{||5z^vum#hQ9jYxz>=jpf861e*Vq}2+;_r9Q z!?KgvLT!f`-QCICz+9dkRA6DXp_HcCUaal%zpIFn4Op6B(rPjd>$_E_+50JB)%ELC z;lv8#kJ#@aeVB!NRN#*oH#j}NbIo4LVMmY#&`(EUs!ZFp@?lUlhND4okC?1{8$+!l z_OaQtCne;)Vhb4@uC>yR#ju)wD3D$jI22z6dI=`{osG8Q)b;xnZ98AJ{JaPPUnCrK)qV(89^p{r?b&46ZT<+6TglG<()IY@Eb4xnd4T|_X7&0nsM~f9H^$b z)PmNbFCcCx4&ucwQ6iSB_7RjJke>rOC4wvV#F<<=7>{hwi=h-%f$`CZPXDtl#{7`K z-l!YT;MT*73r|-eeUvHx`nQ%enmvgYjC|W4T|;_jP&xker6fEodXsKuZ8u5rdOw>- zS79@Ws76)fMc{{1w$&G!z-Er+J*aZIir=0aU{BRl_(jXR3o%n6ape9eJ6aX4yK6vA zb>%w-oz?mKPb$j$NSHuGat235t_$am&}@6s!_ks{FHmw&`u*MFZ-t)Ab4R96Qx0oX zLH4qpEgp!ilW{caPu0r1W$E;U8_w)z@rEO0&ZQ%uA+?a}VRzN`bWhe7@O?@p^`BA3 zNemL;sS}kKaLBIK2}e3qOmRYQ@~q(b@T0^VB-QNoZvyEsI#iL~*>)yT2io-^B0$xo zM5=J@+Ax|r+P`bNkrYiMbc~%ii(O3&RAHEX2)l2F50i(ksIrv*llYIh|6>8*ZB>Iq zL$si|1K>c$Lrz{(;$A7p2vw(tfS0Y~R9y7e@+g9yF^X%aO|s6G^Dz~E-p1@JpFdy?M(gs?F4f(_xjThiNJ<^?(+0Z@LhFuM z5B5%dU_9mm6lK2WP7Bf#gQs{zqYALzw@AFgjP4-T_|yLX!DM5OKUH_jzH<}jDpmy$ z@%b>e4HGXbx&?{BQQ|I?VeN`mYGo}1nZ#4}9E*-O!+hY=oxN9+bxAZ05K~6pgvgye z0?bKb#R{=c9?X#Sp@%LBkh;Q?JO@M^3BH+$DVAF5oN9mv!yw4NsZ|>L6e{Z*lxC48 zZxtJcBu*W_u5y16h-iAR`iiSGCg}RN?gvND8iNt;p4?~0pMZ{xgi3{Pg286QKQZY_?_(u?m zq>!IBBcDFtOYUQj+^yaF>xbT=L7o_h-JmFfGBUMC8nK_ z#qO>x0_C~!)mMtj1lyp@R16j1q-8aQQH#@C$DMH?M)NO~ZAM49!ibVz8ngXr8WQwg z*?B=nTbK?0{!|LCCD}P#phmK-yxQok+AplTRQ^j1&iCk*YSEkB?H1MQM8L8wJ6;TI z))^$){201V+8cK$N@e<1F!966Y+GCLN;abWq~#|*Trl;<;6FHVFVHh-qSj}0UM2m% z6%VzSz77LZJi-KV{U&(IXeW#QBe$vTen*!kuA&IPE9Ay6p)=z>6iF{dv&Q}s%lllc=9uUax7j2|tO+vfNsJZ#?x(^5#VCfu!c5mB9Y zKDd8w7r!iA08Jq%+LEO)kFKk9XzijZXFDPhOv6Y?cwPF#-|Al@Sj=}4YuE9ptj=@@ z0;n=<0nA8QLP<#V{D?j=tQs}1e@h5^VFbN{q%7qaHL?a@`c0#f#W~?@=Ftk*9Y&Nw z(vM==(Dz*!erbiS%U~SjN0m4g3}1=u=U$BkqG$+^1FWi``=!0F#36ew=+}PjN}SLe z{hdVWo4*@tds&+A9{PDNeX%rpv0ud36CTPWL*q`04s+1vg@W5v*}MVt1L;+oH(FHj zk_Z6op{pb8aOg=W6?D|dBEpyeC7EH}AGYOk(A&LAE)0U$^KsQzUhTrRuxOD|l>_Tu z-M#o^&zWZL%r>M>dboY9Detc5&K?=kn&Up}2vcw8PQC)MDqnY_9;zDX-h9PjgD>-V z5zH#y4-Val_NKJdWCRdtL&?wf7|Rp6#)F-f3vAzfOKm-0M#ZY0t>U)hc>JoP7%rQwim^CLw8 z!tc{9xithF9m*WK4mW`B^HtV-3o`;?ONtg56yL5a@OWAqGZ>x6o9LY8G}PdTdBz&r zgQWD7jqn!Cz?JN1f99j%GAnOt{$x>`%atE4<~uJD(e%jpo}(dVN~OXYcghC+Tx^W1 zP7qGlD%;szb5n}N`6-yHzWHHSZXdyBx0*HDl74URcsgSQEr|xui7@mLV9_zKQr#*3 z9vq@)EkKP8j==p-l<~`3)D$%Nr}UZp6;PG@42#al$WW?tSRQ9IOw$WWSJt6yR!h4F zlCefP2ROn;n67SAmNy-*}$))MOw)ftZ z9q61tw}w_75kl*j(CaUNCR<}=Uhz7-xbm;i+-Eqfzpa{XrYzWmjVch+^Nhf^@{4;c zU-dEqWm3|HK)dk^FiseYb^|jV+O@0iHEX0%3LHnScXl>YYkC zEhr)Jt+DMZ9+HLsR-S2i^UXQ%IlpizLW1u);OT0aSMJ{@{eTk*&FfXxuYFvibPk#C z9%{X7pQ*uPPxtl_tm+yQZP4|>fxX!DV&Nb*BwQ%bdt%zo;I;m-oKB_If#z`1nTesse1Z5+V+F)JZ$Nl)PdIR>64B1tgrPTVs>GR+#K9pap?k{l z+2U>~s$`UeaWqH?iO%KJ1>epcwXky)5RElVS%6v_fCFth%{PMFR^>$l;D-rAUwx+Ljt+?u` zDZMQG!Y6i9cUlR({?PwB&yIn#^!pwdEP{dRbK}ecKG@yylfZ9dr}A6u zp@gx7dbxEKo#>W>qllX>gxV-}hY^e+?>^6~IDa0958c&F)7Rn8GyLGMZtLPW_&2Xh z{UiDmAF*4x4nEloB}r9$)5vd+TGG)+Y-9e>?x^tgBakT6B8Rek+@Ra-C`p+~cI{1e zfZ`|&NF_Nk{~^4^G%{G%(KpK2xtQpvyx)mOwc@MDs#~QqlKYp9EUIxh$?+5lS zq8`8(oH0h4??coM-VQLPT!(wCjV3hv3POTtUJTNI-(PmzJr_#XLZFq1cFdFd_fgpY6{|xWJJqo?zRypYDR5MA6pT;r-(*4sF-}A(qnGnPi_Iy6vzB}6nlW)-{e6{~Jy8WpI%zol-j|S1 z67mauRi%uKOV`eu%a)=Obv9Z_zhgU|_g-HTxBpyP<65QbSCXAs%Rk~#D)^>dS%TUU z=7C`-c^)8_Vvr1>*6y5Q+N@^qOI=}gbovzKs|!#9oM5iFMg&bfk-b{1W;DN9_IE1` zbQbVR#(l}>My-RMvSyH4CPy4rl6wRK$7;&Z2JCyN3Q`pYJw=PSkzjuL-aJ6ZrD85zB zvX0OvX=vhFyP_8~jWw7?L^{%PPm2`vEx)x8Q6jHF9S>7%$D3QB#JK|j7?x`?rP7!Y zXbrT1m{<#DVECK~4&Lot4^CKI5Q7Wn-}$8l4q`d`wCApJfS6K$g$<(OR5A`DqZj{< z8LW%d5*#hrKhgTyNyBd9;$X8G7ri)K%dH)zv7YUY#M%-W z%6H%2zEc1X0EN@!vGVX3?FX8x56+e(h`N=Pacu>y=`fSzY_6_m{17kp5N;7BexieE zEXUV@DmnG9A?XyZhuAWfY+A5N5O(V!?|mWS@)81hZHaO{Q>no@tImiNO?HI=2=aHv zD6b`|?vpecF1~05bA?;>XCpp;wjvR$ zTjzbp_WJ&l!=I3#RpD7ai!oIYtETPHq%d3F6jBhKiEMYmcVLIt$+$tcw22z9FxKhl#zMJM8^i43-a zHVuc}A&<}Tq?Od3oi?g~GryQnPMdRkSE)`FMW5_fzR)P=2zig(3QWSvb9c2!1zl?*2IWrh4JJ1L51`@Id3Mi-URlGtwY=(~w6~4%#+TtNG|#==rHeo~xfMdh z3;xQWx0U>(Z(WT(FN9)ok|c2(!816%$18#?98GEz5?eHBoZ!jx+9%_S)3IwkQ@f0x zeU%W1spjV9M12?ztt?}2Mc@ujzBJLtIfVl1zpoYUirqrc#Ru_hp(yQ}2ed@y{S$2h zI6dQGt2fFVMI|Z-Zvp?aV@wMb;VFyg9WvOlMbG8VgnLqHxb9P(Hj(@@!39T?$sPcy zs?N?vc8@X0d+QI`Gc-`j{)}!R;9bKLhkTgHdfO5Zqd*tyx1l?EHTa3Iw(&uX1N>?9 zOSs)sMg&Y0dJ2-2X*MTa6*=$LxfE$Cm|LiRgfuk{v$^LA_b0bhaS*D}*X1e!F0F_P z7M8IZnwlUBWNVW3No7Hb{EQ-g8lPyTq$I)b0B@S0qxKcF>l{&QZ-TDTKaKjeuQxCj z@l)GP?1WJ7!72=xQ~fqMb&(%0?-)nVmv!^LZ>ut`FHIofx6qQxv-eA!kbaO9e z4fTws>r*_@YduWSZXX_03Z}oi`r!IO?kcc&j>d>`en0LUR~H{ zM*9F5aPH~t?Le?p!+HR0*wh6EKUuxQqT6p*`5D<gc!0h)f^aaX6}@E%b;0JU@|h##C{sH$t^W% zAJ$dwAHuU$Sc&uynxEIpbkbYFs`Sn6CV&tkb@$KB0!|Bs8nGG94B9zguK`2MakQ8} zOTUlX>%*8S0|yqH)E6V?rR~j+cl(F{>%pe)Y~F9FIlXSuHn(UrYJO)e>RBg~N3EWKkOfT9;pnCP54CiiL zM~c8^wT~ykW=j3#iIR#3z~F-L#I84hU{r5*aKU^Hs^9#-LKU-83qv!7Mh=D+0IwGNi@`5jRWaf>&cg*W>tLRlCw_KM_vv>AtYaqZX|=G-_@rm_=x-(?NF~|g7G%Uhi=W( zIlQZPjR$%uF^2usx}>t@s5kJ%?@z`c<=Y`Og~#*){n}J)SFa7f66WRMSy)Zn5gV`A z8R;$8+W<^NFDah7<+WBkbSK9BW>tjX!;JSD4O^5FKCB^2dmv;vQl)r>m8#cekHzUH z4J@mI&zyf69ne|%n_aI#zUcOHZLKE+vZ=Y*7jQXqTiAS#Up#gxw7Vo1-_m;f+IHWT zaV#g}Ar=s#0HDg5K{VP4r41`ZrI^N#D7E7h2Y1EQfm7iaPb%p5(cf_vH(R4elEc=Y zr9lFTP(RT%yx8AN=T)ZBv^3mati`b%YO9!$FWhX+hG;taZBghWPsU9@NP3Sj}pO@-^<4N@3` zw3UA0?skJub?}q!^PI}XIA3@@>Q*&n7lD&3lnVkm{|`o z*?~PR+vPQRMFV1Qm1xht?Mhd| zSOJnX@|?5(Q@?7xg{sXG13zYP+<#;XwKZ(nu1e5q9|YbP>~`Jp<_ zs%XgHVa_CsMZz^CL=`}%$NE3eswjJA%O_36e%H`f=q4BA`twcbj&wfYpsaeqaPA+A zB#ZktGEp*F0l9*Dsvy8Y_0}E$rwB71wAji5@!C~(K(=WQ7Z4TlnHRlCa+1irbsyu|3Gu!G zDRbOC*CY?bu@m~9xE9kIgdLCS!qQc%nQI0}oCIT-fS6gJ5#+Ls!|US0Q2unh17Plm zrpv+;z9!OpaD=7x@UCgNN9+J-C)cQmzbQAJ8it*>)JMW82}^srwrJ@it3n(_+X*JA z=;$cfiLTM@|im48PzN5K(a?XhWEcUUOJ2?85G>kGfWG=^z(*X6ms74m!h zZrjuDnp!S%%Xk_&n~mK5m^Nt;MAH4Mr2#i-E0&=iVB%SHD{3lRrlPWYdzlQK?)h!h z+LRD*!udqieV_5GqZ-UF43)GK!p6R1GT-Z@Ah-S}d+XOkuB;#d<%wGuGMPM)_5TLR zw4T|*&sA~9wqZd3pB^)s4wcQ2VLo&KJR&H=Y#%1khNCpLs}oYFDR@iD59iGo9Qvc8 ziPW;3+u99hRUiwGt*UkLdz1^qjd2;e{~_|T9cHR&UM&WF5x(1*mBpR+v)*^#x(ZzgSn%Bjt*e zasF99>G@S^WfZM9l;avV5)m<2KmM~#(7bZ=BcsH7s%EW>$V}(3!!yZs^&7!pa3ESY z9n{D$FG87FbM@-QQ9i~>&`$Fw<{*NHacQHTrruqD^CHHVjX0>)7R<7iLcB7I=9j+e zA1JgG)0A!TWt6tVo1Md%c{#o*8f9I|$$;d7nV`0;R2M*k9iKD>P{U9Q&XpNV~3rW=Ke5 z3~JJ4y_iYek>RAA)oc~9h|Ii7DIDdw9&|JYmDA{sM_<}Uq{+O0xJ5gZ*;JTndj8OPl~2PPN0)u% zgw1{dTs~=r;OCBgr7iA58xeKH^y1WdoPMh$X zA+%5cY+=7IkIh508%o{AX}94K!_x--?aV3PhgrH0QR5glHIkf%@c$FGVwrDp`M!U@ zI;x3V0K*j5qAfTlXaCPykHW1I!F%`YihD(Cua5x!*%#z<2>#c&8Qha%FIKA zifi_T5EkvrKPFhTvKOi7VCGgVUDxBAUJynK&bQGoK!*}FHN-Ug9rUyC1FC@O${%_w z*E#j#cn;>~wrPMGz;s(cXiO9(uZ_mtNP(iuO$Z1OonCorY@Obl~lg?1RmeIcJMMAQCEd6Zp1#JJG!MQ8^u8P|Osqj;mtGT{meTZeLP4A?X_Vk4p8`FBE zyfnN&4N+A`t*v_X7qWPV+tp|AK1m3*b8Hb=D66$z^4;K?|Cr<0#+Lu%&27DEe_(}> z?Jx3pB}tjKUfgls4T|<#!-bn>Lh7yTgYW0Rn(|AA_8k*^Ud9Q^eCXjqea3!J_&*KX z21>)$2l|83unj5#ArBs*Wyxva`5PPn6|0@?KUS=~5hr^ERpgKRuA#B~@D$oXg@j5U{?MSJ64t(f-AtW5SBl9#Ex{@X%)ha^YuKoLo)wJ5Sm+c zKL7dY5&qJ>89y%%m zBKYR>Jn;C)Pfg){(0lnYN940@>_DRNkqs6|O6usw0FY`7Y;FSD#rgp%(?)6xlEaAw z;B4tq zc3U1g5CGF3CFnzd4%Qlkqt(CyZSn5^u>d%OU*3V-v|hyG43blvTo5cKgs4+G(RVzRT;1$qK{M7cxTiQTribn+XOiogNhCWz-I2kk z#8i|dZzOC~5FX8Q9v+SgJ`XPOf_gTHCnvtyuqC;g(T+jIQs?Un=LHWmAxzs;*n&}+ z=WIOZx&I^ULU%ylP`G^|j~r3htJ1{rl9#82RB1Ydkd5TYaBc>MbjXO&mTEfA#H6zY zrxumI%17-D#~;&e$G9B93{8ma{FR8$zVa)xng*(-QEGz|#FIT>pJ=Ie!|qHU^H`^$ zAJqs!4K5TmAmROjV8sMYiHnxv=6*M&gycIgtu(cLP{Ww4#FmHwo$Y1~avE6qnTuIFZQ8cLHY;M@d3S2r=JUlHNc zRXfEP(`h9P^M-IwEB0VTZ@wfY7Ym6lLO6UM8HA*Crj96ir?f?6LdG23Hp+2j43)}G z3>?bAXs4@Pm;zLBiZFK8VDi?QG66s)SzxRnJO!VCz;ag|wO@86RXC%LxR2*!IluSC zo!cB%W{#Md6#n``64it8&h3@HKJaL@C|I-hm0AHX>She9Tf0A|$T5ZTHj?Q3-83PB z1cCJluI8EIB={NqaMt=s=E!&zv^zvqX8$PvaXX5|(@rTa^42-x{5c7I3wXxpNA@!5 zqY|KOFqSw!TNUMK6?zRD#xBDs7b; zVPn6+oGyza-*-`=fujXBe_ps(yg9DYtxBe#=*_Mqi#p-NV1RugnY2%>jAW^VNfn0} zKRi=!l>Mq2k@k$CY_-!rM5b_J&^F;fRJiNM;*ViXVz>-_^rnAw#>5U##rB)H=o_E3 zZh49j5Ne+NOVa&qu(`rg2v$F31O)5pDan%}@gxDN2BsB({J$M*wwE{JTb=VC@*BrPEz6gGr_YGAsv`HOCij} zrQdz|c?ZJF7S6i%gNLd9)gF4e`+7O0E7MWp_wn=!8Y70LG7i(51Yc2JMIE^)XF7q& zqURJ%0ah47$oGHi1}>N&G^q0f$8bC}n*}=g)k1&@+gp!tEjEqH$=>c88Ixh4zTHK& zvK>0H8;#DN9{wiRIrAYm7QtXo^pr#h57r*GY}na+ijxe0ff2cZ#($I={)e#M^`p_>}2%w1VS(leVP3`$)UL*imxmGy91|FC(IMFCZ~ktPCD&RKu}R|Lq8P+ueHmXxUiXF#6a8F`v_v7bi1QN z4-F@T>abMQ?>__=jyy6ZB!&-%C1TTkYf#v=y~S&bk$RQ$3UB1)#9~Rn$B;Xa*zp0= zV|TD>LfW|vJcr2$>G?ROC>!chjScDZLlZNNjL-C)f1x3f{RR4}2^_&yY9+BXar=Gp zo_Xl-JB}Z;^>0tc%j%y*LNL0yuq|UncvX*%SI{^@i(6#b&cP}(5TPAKw1WU(MF`7v{k+H z{u_h^*q99zmM<==1<40Vx!+n#;e+jeEeFuHHXTKxop>9#e!g)^3CL=7l93qSnmxY9Sx7muL+!`vRrPs^(`!ZV@0$IS zrHrgynK3f3vORCzN~+00*=@Iu3!Ms6d4`O;NNkqsSvj~96wzJkq?|D-ooK{;(HLJ7 zT`|R|Old4c%^1s*7Nib(a^_H~E(k#g30b@XVOXN|z$YdRwSBVsmuF%=uXTGe)f_bK&F=R>H zdCTvvRzECB8eX1yW%w%x0&)gFv5TJXe_P-vyUE6tE^QxHt1@^WQ-qN38;NZn76gH0 z0wb~MX7(6S`H5+$zu4OFO`JN4|+$xIDMm6^KBJdY~EA!JWo9Jc$-Gt%i3{}U7jeJ z+)JircT@w)P$xkIj~pNTDtd5;TpyI^LzF)9=wh$~WG_Mxx2@0nLNZ1Ug4VvO<`H%B zMnbkE*u4=!Z{IV8HjfUDmRtWyNoZWGDI>-M|ERT5pwm7K@Y@B_a8b~w-^$c?e378W z=XM!)jkD`Cx7aL)nx5ChXDI3mRPtI_0tIhQ1dl6~7}3#K~g!F<`#z>&u7LE11qEN>i^ zF%CXIW)AU|5*FHMJNDIC9@Bw+WWcZkq^ap z9~3s4*X9n#stXS&K58n$zK?_@6NN?dL>3_=ax=Q`avAUW`Uz?rQS}g(Q^LJt%AIleDiUSmDSX% zDbgTEHCm}W&DJ~X$kCoHt^?}6=t=S7U+iu^f*AzHr+oT9lmOL9KGb`K6Dfo=(&E&j znIzn5C4Lx3aKUOY>DVJXEtw7~D8PO3LNhHX!R;pZf{{AsxFTlShe;B4$)5cTHkZ-2 zlbQy*{xqNT{T+?w{fF45b$);m1qT{%->m|ttWw`@iT&exN(N(8-GvXyi9OZ*FX4Jj zE#rI5Af3ickpq7tMi{Hlvzdb5mJ}rGZt1o4RL*ypCAu4%F7lir%;H1VJK* zL>GO6=ygP|qn99hXY@gIq6N|G7`+71JEM2eNAx~wltjX)2_`}EoP2-p_kDhAJ^xv= zmb1^^XYc#$bMMc6UAJKw&Cgtad_&Ye7Q=BXq#=sW8&Y?~__O%Z_C!AFje6gE0}?q^K}OV>=+g|325kxy$+OL_ z(8-%A(gA)3mpASoLL<4*#=E*R}fO|+r_t_cWIx57k^c#yI zFKz1m9D3ClJoOj1dc?j3l`vryzs<)v)4BQH^10LE_5{A|^0cfHouUHoYf+Wr&&kTa zF?FNJxufdb`kQCICWwFdK5B#J=H3~(i#B(~bE3WVfDGmW+fF&ep39}mftgZ-?! zfr%tM<(c`7s>cH-KuE*3mCI7yWv3g3T)yQU0OT$O{$YVnx(Q#P+fMgbXCPov4r0?V zl>J+>uWHDbHeJ9FEtxSnz!EZ{mInga?EZp(%MihRDx8YTDueRJ z(8f;vKK}*v0!wLHr1CYTtwPV)Cle4t9CoGl0$l0*Iq=5iPAshs(gXc~|P6Ax( zdpw>>i1mY)HgO-mXn#8AS|C1bP4=R9%PR2p3CZ2oZt<7T$vE*^OjL*OKfpf$=3D3g>C!xx`$?%c5 zTf1OZq;Yrw<91tb**~A!9xcG9wr{}wZr|cD*a{|#CIDQxLX&^8Okhs+tLTH<3{Yd&d#(3*wy9Iu-vQ+bmOXwYxy`QhZJA z#ra}Zz72N5S|P?*Hs5(_|6A{ZZtWjX`y{2CFIzrlEq{`HVf+P$DVuRCA7yOX%#2eH zxa{67p}Cqe!|N0#YO>wN*pc@MSo64i@~>qh^bzlNAC|(t1yeg^QYx)7N>dxv@Alpo z9xwI$l@R4&Pkpl9C*5#YblaxX21c2@t_DwfT0Rk2-Fw*mw{av3gUH|}l5+WZK4DMj zL$>lMjm@ONx9ni}ZUnSd__2hemp3JcC0tdBwy0OL&!AS;M|x2_Ak!2y z4MVcZ=EC2;w*PaS7e<+`Q8Jf|yEK#X#pgEZ>+Qs|xID>gjDCHX(KTqDbt5OiYq^p( z#Oy7uWP{M*R9OR5{ZQna82I}MoWeZh1#7thP8u;z(kg*A_v7b9ALri)4GTT~qcQ!4 zg74oL&$2AXogX-nch0Xct;H$hTr8g7yg%h1ysODF=NsnJ;cZcEz^C{ncB;L`z-6D8 z(oeuYgyo5oAb1%eJTAxn*x>8)2Q6cNWBjgfp9LQwQs|f>o=V^arAnm;HWjx}dq12? zdX?%rN$2Q|j|=FrdOFO$QHX^?C(1jqwL%EkmVfl+LKEL-apB#Zj*b;7x)*kT+Nd=y zucQ%^pCBp!>~L9UpeMGwyA00Az6Y{kuAp^m#mkH>h{6Y$4+XKpXP}&blFNWy^^(LE zse=e+pTiQ4j7Cc%5`oQU2`a>bBjr|Nit9P)+Jh8S=6gl^`9J!9bxNSEw07!>t1s*6 z8DlqG-Ze>>@Zc;Jf4O*uAH-&J6#KURT5LZn;Dffhk>ap9Amk^gcdxA_xJy@1LKYq4 zl6}OjD~~vC-Yk;}Z>RdPgA8j8y3$Vchb50t)~%`K+q`utsAZ&LnJCPoSLg4BkdhpGeL4ZjklNRTRHKN4GJJ`sbU z*hVv+A2(aII{0ent+|FH$AjJN>T7M$a-n)EH6-nC)Y?tXJ9FpL4kMp0!+wE$zwU*Y zq}O>&7SGnTB~}|(Gt114@fet|=}MeT|GiQslf6uakBqtpvC+M}Y-1QxV!I9-g+`bS z4S(c)8YM_;k=lboLSjBy=zaf(N5-An_8jy>g*3q`$XgR14XV2UzVhwcqu_srx$kU| zeOyK~lkP$xx=4+mZ=bOq9~v&VgE+6nC+r0U0shj+l_6+4zFBIWiM^2vFd`(SKGcs{ zg?9f65)&YzXS8N#)KuASy}Z;>vnSx_I&kqGyo;u$O>w(-q~t7c(~co3wc5+05*%~H zz@Fn9q;m1}qpPqmvAJHwZ*M{v2x|EFcpdAFe<&^9+kv&q439i}NIa2PNY~eq1D@r1 zVk=boYUGihlOw_j|L*+MtD{iK?GG>Z1I8N_#@L@bEAmU2JTDLoDd0AQe|CwF^^>;D zZ%EYgBL$_x?>{)@WQdijFWJx_9{T=`*30NZ&#&gGouB5cS;WhcW>>1o_SZ_U(e1#v zUcSOr8l`@*nz6bS+RM;$fMU81{D_Ttb@?Rjw0K0hO5eemYzDd&HZqFKH;_C4A+*=q zXU$>AKGDHJF5`mfLeOl+jBP2OHb=m;Z_ySsllj+Ia=ho6QW*&19E^f(WR$}=W_j(A z%%#jjuG5(mwv^QLROEl}s4Eezv5;b4$=hFZbnV$mJXF~66&5Zf>1!Dr+l>EY@IVRc zk3qz~8##t*2=F7+(LsnmCGbaJy$lT9v;Iz#>hy8DR<9WR3RPv|#V22M?-#ZceME?P z46X0)t9is;Z-HzuPw?9)uX=oprX6%0=NE3-O^KUkm&Vx=UpB;8f7ODsDJD8*)9Y=! zL4FpT!{T^+=h55id{o<1x%LChtCxO!6@HsQnlzD+hY-jUwiqsCTVrJ~$kGruHd!2-Z1{c!{!IB^}LPIbm|LvjbY z)w?Y!jsF4m%C5fYPM=SQ5onAGZFDsR2*dSsOjf|CpeHuVi&KE#YDf7<1<7y9`&I|HBS^q#q;SoX%wZJ5AJ35e(s9lk(8?TzWzc4}pp zlnN=@zS;4XDK~G8o}ek+ib4<+tblfu2;2yy;4GTeI98Xxlq|cbF%6@H~8!RQy53l3Jj&5&f-r`1lmfDP?yqKGnf7>z6#>g0Ud2YE}rzwP!k&C6p;U+PgIW2pYg9J&bu$jcV+rneolSFY3 z`j-QR0wU{d@viNierv4l)?UsyQC@l$$^(q{_+O6hEcV(DO!6 zA`KtMKu#>vuqrNO6hRo7&)1pccyWYRXik%y`L01hX>{=bulV0!LM5KyBB?Zv;{#R7 z$PV>ss$R;MDrVjt$(UPt&gq!erI1LyHrqZdGk!)OEP`tFpx6yQI!AIo+VI#(%~6b# zvUq6jK2FuOqqlNYc0GHsJ!-Aj{Z2n+RaLYsekPY>ul(48Xapu+Jez1m`=Ylwa*$QI zF@*wmdkP@%ZnUctlA2D4L5D`b+_t_vBb7N6PAASodx+w z=Elk3s$DJ^^DPjMGtke0$`;ueAEi#kyuZ8AWyuI+FbjCk+o+z7{n;+Jp=PuWnq$JX z7k0?yi zBVZ_+;u}q{m4(~ZN|78Th)$xXH%RvYYaU5iwv7#EE zldh>=iUp8r8$EDbih#j2)8a8NH7<$yr8m!o(N};>n>(abdI!IdEwN zc%;dKGL-ssQ&fgPaLq-VE^*%`R6{*XD?`qj>lawiD*LIbZ8l99*w`HrP5efEKaLDM zHE*9aq2#2Id=HwO*$;{mA~7<%96nqY>1BQvGnz5KLY|!e+D0TiT_|ACS(WzrmqBgs zhevc=4&NL2z9sF`_8mhv4@5nO)V@w?Q)k>? z)8*Vj%aYyQnVvh_#Wa=XPR=w!U6XHa55yYlsP!={uDoHjjvALg_NT>mS*>6|+Sr7< z*h3CX%}6SXAoe6XmpZfQ)%_?^eU znUrjtp8W~HPMicYKqOmNN7z(f`A?U7RbuHc#GY6RSGkMG9gjwzgL2oBP(m5-;M=ok ze(B|nk5IuqbG7!Ud}d3;_PZr3!MBlo;D(x0Tga53nq%Z(i9Z{Af?;*tFWyVXDP;)5 zDRby~hDtm)@u-2+Yc{I5V@lR&*I?F^RI)H9GTR6dzeaD;(HwYZW7!2gvFOo+Egz9r zQm<%meqWhT8Td~wg?m?5oZ{tX%k%Tgw#YNs^K)B@s z5)J*IS&h(u=$Ns_B|}S8qkuO`C-x95i*%mz=(j?V#_5?c)2#`Vj3j zEr}Z<=ZpSk%U$xyRkyzHPozDYB!u6kv~Rar=g6NQjVkJrO61%BusK_N7dOxBEkJB0 zYOrZt)lHE9wJ>4DUZkpXho(y#PG~nuyZ@-o)9iwu*wKqXPkTdo-YQr)al~yE0+=xO7lPWy=&}y{{>uXN7(p4= zl`ZzzY?0Ia=6lTiXiFQKNv|QuWA)1%j=={=DjTezG_L?JP#jFRXlNX z+c#ZlRr}oPFPbB6^nfcS;rNNIoa2?|5M`pyR3>VHaq)uA3+<<)?`cUUIIx4%vjWAA zF3qe+{l4>KTEwXVHQjeV$sq@;Mm{lO5?CRb!#5nZ35wRG z1#pky^7lh}yRXS3e>Wb&gQv1FmVcA-FdACmj9Ez~IDIwdi5ivIfFSK2{txHxE%D&}b-wbLghW?bk z*qZKONYtg*dlLBGA#$6Xmki}}+{KkUR1wF^#ozSIgC$~BhdF81Jxo8JtE%+Dhq<_Z z703Np7iV(EAB(Q#?Szapq|V?Exgt&XnW$q>!TO&_p6|ZhLCUkJ6U zOBozZ&Lv&p-MY;lj}ZPY+1HM+`6S6}4^GSs!sGnyWDsUMZPB#}``h`7m6b-Rq;vRP z{cd_%H>FH6h0PjbxHUhZeQHW9!-ScAG#Ja}AogLZ_3$UfsG5XWgmSKq(vO2hF~H+) z+=C2-8X=VNM3RymGg@^LKEuj&5*nkq%}W~BRJt)ENh711+poZb5RL&-U~EyF<=*hY zVt-~{&v4FM5$Vm|z$iQ6f>08TO9X(4J_pPiJAxyUMUd5+P>};?w(4?pIrcfsyi|zp zQf*Vz0)OLyO`668ER4}92h!5IT6W~D{Zfb zll`UaBXosl_<`sv5cJ&+GW6!pbwS9V!)ckuY{}9@o~L-fwfWWDzRhFz-q8P!S{TdC zzWbrSLWhTu*;<#X#f{wVJu5+ek?N?>m0nyoUey0ZdqaG!wD|q1ipA`JOj5 z2BV-A|MCm#HcMC5r_9T$YAP?Js-5jy#v6GMod+4^wqF8S2*H(h3~z6vfY2Iv)tk{wMUGN!*QlU( zv}(3t&^m8ZnsS(K1nsyY%TxD^{EMfpvOC{1A-XSy&@xX>bi+tH=CACtNdmzA6YQHT zdiq6`c^IbujYfwxXL9NMd(x{jfrbxuBGi=@PfGlvTi;&FBd%_k(QY~KQ zaP1W50lTp&t~SBDAh69X2vfIF9CCdYsKcE>H1BLtnZQINU#m}({`>nPAJ$-+LX9R;tPX1jS(;_epDh~P^(ggTG)dJS94reN|;@P-NCwv@5-xc`9o@^UMvOxd`yb5Ib6 zsC38$PBuoya+}2qy*NT1!>+?blz+7?|F+fjK3uWmBv*zqYchaZm7|~A)JtinTSsW}X%G02ae8_C#9-I-Xn4h7Biqnudv85fmGyak z=f+Ws#+gA^dSg9q9Gtc-0;m(gJ9td*{;|wEt&1+z`gALyB0^{V*FI>oYo>M&hns*E@Y|tFc z;gp0QEZJEd3JRR-@pZaq8pnQ_O18;-)cTXu|GC5OH@947%8qi!zpwFQF`0mnpBTCl zG3kY5{2`e0B;i!S;+-ebyQefc>N)Q01h2|*e9&$7-8ZHggT7NlsWk)} z(Ja@aG<3P&>uzQgn$T5q3n~wi@#37``ih45HdD>4mC+X+h0IhN*qa8VjJ?rr+e0jc zY`1=@RmVzr1&^Z>yt>tJn6RJbFVZH?MaEr3y?XqD_?q8se9DU+7m#Smuwr@@XG6DM zwBz1OLMyQwyC~=2o-p>uRaa>+3p1_zPwZ{oUJelIl-?FYFH~!_6P_~=9~5st$c@H2 zRlvYkDn^$VhY!a$6+m>a;k(TS^Z=nJ$bQZ38l*7~@qf3a->DLXBhXx3%Wp>aLxjkY zh@D@Op~4ID{fg6nSWW$nF?&|yl;kS?q&pPMtV_eg>>PU2D=U&nKB_4m4~4d` zg-TeRnJLxyp;}rAyL^*h>8dG;XqJfHPn~i_4CEm`!x=A4nll9tEve<1Sp} zUoYph$EUpHGfrFB@5}g*^>QtvLtOEhMOC3cyy4~Yonx8Q+gJBKvrHeSB7)<9F~s`6 z(*RFM9oy=H0W0R?^M`#U2}&E)??HAzR%^s34UHGS{#gvIJX1wtCoCJE_ZSg;g>G_i z7=jl6+tk9IB5pSYJ9zmbaEyD*&7FnD2ZyAxJat5&!|n`Q0X37=!6T!Ge&@|~eQ!sr zvU#^Mci~{8PDVVTdx>&^&9}jiAfrx0wu1agBE5lnD@5x9O})l@5+R=cMA-rdxRRlO zc$Ew{x!9BhdkO9Ur)2Ru3v{Tb-7(}hXbl)%_YiVZQio}Q{CCCv{@jP0F~8dlBc9yO zq%OqY6Rv>9XprKl_?}EJo0q68epK|$x_g||eiPL{91=St$NaFX?=jJusBQ7+8&-+? z^1KvtU+pR$Z*j?}ohFa*%<2w+M0>Ml)w6pTb3~$!)M~q@sD-q6!JafjTR~qef1K-J zh>gP8@kvee-PPUl$NsP?Vtb%s==;1lwAZtP(J8#=pJ_;>0lL{q3*g7B>wj+&15&N` zLo{qfh3$kjtSyUYRqBudonxB}#L}DML;hs_5UrPXe>tuH{m2R{wr*DeP>+jcy6|`` z(R583y~!&@!{tL}H0qY@Gl9{^wSuznElE_Mq*BYqvT68+WoK`zD?ot~FyijAE;;nv z{|Xlf=*p6$R*HxNN%1+uBgjk-tnW;M9@#%-5)}N5o;21fF})xTws78?tjOi;QKD}+h>RJWvOna=x+=cCtQ`Y4V$33CGbAft%$zElY(>afKqdoMP0mEkL+m| z{c7xsr#MuP9uaTu;IWgB%`xLk`gU~O1a~tk_xc5?GE_>O-=br_WU)Zb{ja*GuY<@X zbfZb-%VmoJd1WO&A<@J7n|_8pP#%)vp`i7BRLozm{E-ZC`1&M9r$_&B4wdJf{@J`P zf8o=aJCOlfUrqQ3dqOV8xv2tFVc0Qf`$({5In+3^;@&F0eKCT@N%G~>+Isspe%vF@ zw5q8Kk9`|_OS--i>oQNg0|H*x+bn6ksrJZf zy=n&2b6oG1#G3C1?IsPYE5gH{SNj$n1O=xkv3mv|e!aCI;vHR8M&9&=eqEi^wn zZdoIRED>2o{E!o2+zO6{;N)de?&o-*Zl!IT{wP^g@g$3SG&#zaQJ1!~R1SXWyA+2v zcI(?#D4l=C=P0%~cqbTc|H!N_s$(YoI9Oi5v@V!69IUgH zf44}9R_W1B;&qIf8dZnLYh2RoLyOJ!2xBlkQi9Uq zKXkrAg~ljb4q8ks=n^v!JPFBjzhd}vMbIn*?)UPN7qBs8A!-O7)|-$!y`yRntQ6Ny z8gy)>`j2s;a1rEzLo&Gu9>?{XlcS<=hvys8!dIwRk_VwGSv3sP$pybxxrXOYZvSa#! z5hKg9F&MFR)tX?lV3`&ZhM=QPkcH`^!aWZT z^<&Cv^tU$F0rWBnWibyNvOGwsEst+tyH*w1V^Oh-o^DQK6IUkw`osQh9fP7)8e6kU z?DU5z2i6AklhiPyE;F>aw-DvR`2E>$T^n{QeBG^Ee-<0P!>v5NEgCRP^>&7!5h}F= z;t5{;_DCmSkZ5yydAwQZu|ZVZwYoVd8@*&r=V+&i!;_{;r@h|$!J#f)0YeX4Vn22_ zuk&lX5mx**oa94D7x~L&eYQI(BO033s(@pCc=e!RG++gkpOFLQ&N#b!o5dc6!g2Zd zZMnI~j#VtI;VZFl(A$nn3Hu^gBafJ)|aJZmgq*IC9l{?UR$dU8TukI<|*qG zLbu&0KYi!L0^pF%R$qw~mZY2+lMZ(WpYDv_+1x%>PtJ_?5~ z_gO!sPN2vy>4urP+E8qBb-Q$R%M;C(L%zKf#NQib@LL2a%ZBqqBIK>;-cfh%y_(fp$HAHg$|ACvbm1?bV|M92)|=eD>eQI}WxScBcBKQX9h|n58nI`g&W}tMt>pdj$=Uwp#w6fL6Mkw{g ztxDIUNR=)rXoe;k<2_5fx@aMW#O>-xH>%tgicaux<8V?@-5kZrkXtlG+-cxYV)DKp zzd2quEH(ZqaHa(dk}ScKans-T2W%@c$9~0pXSv8x83YeOKfNX68La1hg`T@=?`-S{ zi%brrMf3~<)i#VL{tf6VCxpy1c0@7-1 z*B0770B%(T9I+TSY#=^Ye`%}nP9w7!}gUVT#P74iLSRvNJ&%TAZX zrZslg@PN1|(NJi$>|nf?jSP{p;!L_ff^__J>AR#!88PM(*0|$zp4qtlMD(crix>;c zcK#R7>u+yH*tm({uJqfZBcneg1P8Q%_~WDjRPOMP8T5|gKSAexW@o)|CNj=Hc-P=U zo<_X&!0)c#BJ9oxep*lb$mn43TztbBJPTv{a9oRqvB=;yTHiUy8#uAqfzh2)jZe>* zQ+yTi^u#l=vd?A@b>%Iw_$ha26QUCZThzN2aPAI1%igU;jiyz2?ia0>#8zv}drdsMtS zI@XOo5cZW0KT|s`*1DK5@X4 zY=Kb;$!_DF?|L7G!=Fv4RDDyu*9N&0-HGF0#-|`9@d&XzaGBLmCoE%O!X#*)vg&WO zhVPo`iF5DsMVXjwy<-4}23&5J>2lA~84kES$D#A7(w`RxXbYk{&488Ts4s)7c}WSD zt}9{-)DDC32ASEj1giSZrf^7f#YK^|I|iBU*!ZME`GgROIhdo~5oLa926Z+%VC?~H zn3+M!?M=NFLQ0+eRONw#fTK=i*u#f+<9BuQFLqyt1YM}<$dvBG#*Xhr7Z;&^Uw$m$ z?jsp7lFT59Rn|e{;iv}0wb}>meEN`X==OAsffyP(vyAmu7DT^A0`KWWjZ3%{QjT|S z_@nuYhjzl9pg?%jKfG#eF23R)UbSoCV}&$E5U0Y7|9NQ2Lf!N8TL7yTF?2aCBDlWU zPPp&S+>hRy1HE$8ds1#H^%eStVwr>uH#;ZZ?s;!}wLec^yX+}{VQNUM*Qj5$Ty(2a zy05uO_ESw#-WpW6Nw&`VU1;=@^!#?+mh4c95@zFm}+WBTsx2co3R?iy! zY_=Tn9*XF;6+GSku=O7iQ4zx=_hsC>Zo`j{_He)GdzH$lJ)E^$NaU;f`XPMRc)i_2 zZz;fnqGRmIedLyY%RxD?wO$mLSWDFaKSnFsxy$adDw^og5r2mq_&lS6lZA+|!h4uH zU@ZMqs;L`}!eRsN>9Vag`k5ojY4K)eX0Nqc1*J2P318dHgKp1GWKRBE5fLEwlf-?X zYN*YW*T3Hw$sD-gR0_&utuHA*r)jPTR;q)Jl6QFsk?IL(f)}dPQ}Th#@BAD@@!b~h zTg;?B8e5@=t{AWLcxySyO{wz>2-}b1WwkDCfVVO4m>U4e93L}8z-FNJI=j0hU!azS zY`Ll3Wa1zH^ibfUnYq+=m^Vw$bO(kzHzPfw4# zOu1gj<>lq%|HYDQJhBr8W;m3AE)uPj#k1;o&~k6DvD6GN7Gr1d4<*4DmS#9UQtu#a zsp8UX9f7*!V06PUmj0VtXYtzI1;FL$M1Q={L7Th*270$_hp7udse#zVbUif~O{%S} zW%RG&iHXX1-g&W&*$ir1+z|&r7pET27qJ|35Dn%K?6ldHQMt4)puCa3VdnN9i$2Hi zEPFxgXfa2f+D;9sfPFJuw6+X)`q^3X`z!UE9ntsudn`Z)zpS+W0$LqOq~l64r(nx< zUfc>*v=grMhTE!8IUN-OEVmV=xcK%qxK2OQPMCx8`yVB9k(cPKV4fIE!Xufw;>t=G z9Fi8#t*3FxNzEwwj_vvp9pmy6pjcM3DBh3Pcg?1GIiD(kE(qK#7^bp(k#Yny28?aH zhY;IXof)u`n#2-P=Z-s)%-IQ>rx1^fn$G|{&moDrkLRWOUb7i|V7vMYCCnSq`RN^2883?Dzhvavs|C zpWXotCIFN7)jen^M?x|nI%y}I#*34fECGB5Q1An;Cz?!MC;>FFvwppv7PxT#9=d8X zmsi)8EjJcJ8$wm3S#j1FSWvBrgI;4X$CJCnbq<2HG@OxTVe~W+w=dEW=9DS7q(zT^ zCGX!(IUzUxXG}%iM)1&!^U&&%Q9ZYyR~lfjv>>8(+>yh)gy`QXZC}$t^ zb!EMJzA9c?fbb-a12_;Lxyy0s1IUswVq?G?eqD$Pq5I^xDc2DL1>ml~B~O{IEpecs z`vIX|o@7AhZVGy;U1GV=A1>QyP3K20g^FWErL5Qzby(MH8Gb zSthJpD*YrVFyx~nlD%LC)HTdbH|S`e0(HqSXY+%zM9mgjfTG8sI`GM3mhT|(o_yQf zZsk|;B?&Ba7c5MaC<@0E%~Y5-6|-h*qJBZY>SePi>Z{SkJq09gHk9$X4{r_(Xyp+h zUU&fnS$w}Z;Fo9E284mOBSsXZL?n{~1w_9OA%0|@r?`G(w02EhD&hz&d-V5L(PF;i z6-Z-VdCHKitC$=NPDlJt3Xzw*x=I<7vB?pB4gv}GK~IIzq^U!r(HO+7a08G=fFh0( znm3;NY5r>w=-#PzVeb=%yBbI;lOUuVf*7=>0qmPRN895;pswi(2*){mkA>#vm!^&b z!w7ct5OTJk^iLrOz{z0ShVn#xn}N1kJFH_6C19-{v-IJ2pLuM&n-N|MMoy>wr&l;( zpF_iQ+0lGx^Sv|MYLvYn+3V9>k*9R*=skovOUIb3Wf7k{0Xl?|_z)sai`}+VMX;?6 zRUKO5p~139k>RMs!OZ2SpItN7RTxnWN1pG)075tV4BFl=Luz!1#A@c|78Ir zVL`}%G~#{^+MlPNs0%PdmU|CW!9QT&Gldqy0Bo41??)qSI)L0*3)|ZAkG(G%Q&tPg zJAcB+I9mYPQ&awyM zZ9t3=QcRY%4&uTGpLiR<@0+7MHI$Ea|Zvb!=8}NSg4GTFfg*N?C25pz>#;e zX|n^Q7c6^-{QV*2S9ayG&?l3}C6uZc)VZ)nb`Fn2;d#X4JSAbCjjAa7fI6qj2C~>x*38)4&OQXMSM|T`_NM1t`5sY3K z?`VEn`xU8Xk+}xgE^q3m*^$vO>1L|C9V#@&zM&sb;A58gETutDvRv#7nZIT#t*=;s z>F5dh{~y&l6H053#Wz(-4S}hI_)-bvz(8oGabD6e3&p0-cVEVnbDRngL}0j$v2d2I z=o*M_{-spen-?u3S@CZ*eLv@a!CY26XjvEU_ssPz>O<$`Z=WnQ6>s% zyTh7*6flrV)+P1Zvh5Ta{yNV@Rg^7yA9-eYXOb7xcu>0Ys58Dn)3*3eHe&xzItTKwoH3kcY>cRpR1CI!4Iwqq>Z2z&ouqy_@E+j}7TwaTF0=Lr6r$y-H3$}3nE1hB69nIuLf z7=)zl@+3)Ixyl(ILKYIy?PB?TBA z`!QyBI}==mfo|Du$$H$uWsJ^Oy=2lxTJP_s@liGAz_Ou4|Gn`PXy55NgpJ+wF(VZa zkO1sm8`#aP4=Eoaj{LEuwb7Q-i>8c$W2PnSs1I^-ei<8(iILGA+1WA3$P7ndR4|g= zYKwKa%CrEZ8k)L60lI$ea@a4ny0Ia3@pqs+A~Am%HGBT`S``>HV@H?AN#Y>_$y``i4ByJTYxFJUz{L;1i!Fjk@6O#ABMt71GDx3K=yk@8k zNurhWT5fZ8HcHyjqMPUk<*%>fRidZiH9?)kQlH+(cR8TF$ga5m0*7}F0n1F7_ZP(= z(@a3XXBN*+f{513(2>OKS?8?nZ z`|>o4KfbznJPy&=+nt#BY!#^jZjW!5Mkld#x$LnJj6XvwS4BdXw>agWyci;3taxO2 zM)##PeK2Rs0Q?J4Kk%`xQX%ri?(eF!A9f!zoCB@UYCTo*g8}n_R`ivv1R1r>_Pk>T z-drvbG66?-Dj(5f>UoA+kysy-t%K8Mk*l7+F@YJO#No%rG8)K$-CeVIGhoK2R9W2BykugNzAR=7TuFtI z1Wo*4lE{SupYvT_v=b%+d{tkR{5N#53s%-~C3 zGthFWCFL&H?W<~dxiy>FA7E8my+n|q_noVBidTA|;Iw^4go}t{qOk{ZmU{ zBd*>^`buDi7w_@=FM09p4!`{ThJg8wfA^|}ksUp~_Lxx_p$cxm+*d$)q)OSd0U6I( zNuzmd!eWnSXqrpPB4b}|_MRBcAP|wvlzy}-*aaz>)5J!bCzX{J4J zAqDum@%QIzafX$a`Y#Jhp)yMzwO_)%=)Wm^RrsOb#c;_%dX78L;k(h3MVW7(Wg2U1 za;iSMJ$#w4SR{hMdlPR4O!aSNZ}~EO8n4xk<|JXyTGLy~bC@n<5_%m1_HB-hH#>j_ z@8>tV-gM*g$Zh)fb5P*s8aAdFLjc`Yj!h=7p88iXMJl&ri!#E(I|yfC<3U53$=xSs z4^s1y^DwLn&Fb&0VPjvCdB?Z*x%@dro-}(bJ-3*D^-{)a|LUdso~BAI1Dj1A(6#o4 z^+VL=EZ@5|rkH^OhHcH&LQ|ikxT|Zrks_d*y}zV${H_o^_}zQpN%e@3*ZGxAvh{>6 zq?<41YlPgmssjU0OgJy9A3T`MAXqY8tU0f8!t}V-{?z6LNd{ewn;0HEK4t>>pA1}& zpLeGFR1pceyLG95QsW_b1F17L?riYtWy*ay>JSoHG17{3?vA?jh=i;+b$bb1ai5q0 z55B95_1O522w}U!EnX^d4onEu+IRqb8)49BH9P)n=q^*P5*jxJOPH~g_OF`BdvzT0 zn5b_dJp%I{$TQ=fK2$||b>Ns{FUU|@Hj9YFWDY80Gc#xbtm~|?dcd(2EGo)l8&bMO z*GHX7Y&mZhjwB-8;%a{q7FMq#RCUh)mC0whxOw{aQk20Q>%MhzSW)0 zKL#Jwk9F{tSg(!O)m46UEtO-@E1%1#3LilJ$rHKm|6P@x8(^j1$W%WUwQ5=wN;rqN zlapThS7FbA;F#)`@C8ZQym=e)Cj#3ewi@aDbMUuM4Aov|dW)`33JWk%Ht2ykQ7_job?A@+%YiD?ypcyr6=33ad2P6F)MSzI z!KypO6n4d&62tf*mDR&6OO~K~K^wAD)YP7TTCJ+@U9Uy;QfoGJA?mn%Xte9E-N~xK z&e&?j2E(fDjzG>KhyVRL=j+!*sU2xF_vxDekJqV)qBGOb<5b$HzJQY6rs~ywvh7 zbZOk%GkWn<#>~LrLwn2uqY+B&(BR9{R9qK2u-MoNXm#9hO8M8vSd^mls(zEWNaDA zHe@SFVlc*p5DJk!2V)sq)){M*<=D3fW2-QQ`W?Mq@Av2P`F;Hrj~?ef_kFJWx~}K* z+6)h7`j}pQo+cjdo?JLqS^6#U&?#D*_K30T_r0{9fSF`6t*$!M~~_>P71|BZrW=xUM*Y&TjqxkZF(#A znHMO6T_!Ga3%6M|OUkVd@`9=bT>E^9SG59Par1rhG5=|ZEFO|9 zH~q2};&bK$zY)uTdzDpvUu3l)uRGjq&^O`Qu{8c&smt|KqZMh7%1ft7bQSU(d;hVB z;91pCbNLfq8V=aA1@E>?m#4ZT?+CK2t;~3J$re4dE-i57A3VRbuvz73zSXdv?4!M> zKkNN&2ycHVb?Qm$?0heh0=f6dzWdF26D(fD4X|ZH>v^6g>!%{{@bI?MQ~5<>=(KtTEu8xsRc5$UoOQ^zoEw`tw{m|R zu8TRe{ZQ^8o8Dv0%y(9RsOsC%egD(7cv8==Xq6*3nrPcKgL~iJrwb^0XjNK}Gcup-Fm|BL zbwKLIrb?voqivP2Ub7X1(IhgCPos)zKRHAq+1uNn%`a+lHaLIs&sA&rGN549mH&Xn4Edt2c_;!+don}hw64Lo%uR>X-tch>I!v-JpFsS4+jH_ zS204gXL_5OP8~N(_N%tcj`6KkoMgKD2eba6^tyF&o|g!2|^6r`qeP(Q7!oy>y-L|RJ(8URXHc-%4+uNmuSG2YQ)2%pMtkDDOZ!oRr1xEJx;Rp5nAnP0(c$0y=a-V(Nv@Z`nBi~Yb%j-$6u_|;d1)f)*gHLZ%Eqr188g}3WGVOQgJU!H`Qn(G8Y&4xMEq4WDjbH5ZXA8|ML_iBtsxS&Q)!ddvijE+R()1dgsn){T z;ETNDB*Jim^!lpnD^1eesub< zCpaH%a<8}@Jg_7t$92oStZ!GsT(0T(c1LmQp#k4Fjz_}J-+XZU!LX-O%Oi8c&{tpX z`IvIl_|)S}3?DXsNxZxHKJWTrciHDAD%xA)6|XPOL)v=p#?to`*!IF^<(>lDNcIVc zOEpTS|EbB=!=>sgKx}|U9A7E@uqW(H;{~42cyA$PioqT?>cW>AoNAx{mm7WY4>!8d zZ6K5)41PskOQs8(>2UY@Te+vSf_D$u7d0Xz zN|n|DRam3BV9 z5YW{(7+7q+xM#Bit9J{{H&q|hIF2P?db+-N`k){WhdHiL8B$1R3|0^jT`gYDiblm* zWpM`mjI-Kkozzn(!OE?LJ~D`l_T(V1@m_^kaxK2(c8lUA=fv8l~Yo5dpbvm~C zeN=L(vzc_?=LYsi!n(=W)A{^=5LEKMI6fn&&f;tpAsX9l>~y4l-9H`smIe*{(M4Vo z6;A1So4M*oH*tOqi#~N$^*xtm9G3eEbBFm^x^k_6Y{}{@pA!3%(gc=-l3QNJMQ}4- zNHbyHww09}$3t6q@8~^bRuq<6%2^o=J8W6TThRQSZxdZJ;*F~;?76Y+e zVZAz0F%s!mcZUNPBAHcpl;icun=lsvnqyj*Nbox@L3XwGv~IB@^$;pj=kA&nmIL5M zvqI-kdLt18X(*IPDLe_k-+Xf+@ShrCjq{rN4Xh zE9xQb%7Pj!c=UNb~z@dK_wS&I;zyxkWkSTPI1OvEZ53|>exfYC`Q|OZ{B2VET&C3hG(Xy?4;_$D> zg>)avMcPSn){Ep}(*lIeai%alKrxIv=1C+9oA5O`Ggp#Qbx*=MeK{PawlroD|Gw>h zm=@U+_V8XMe&-oiF#A=ctcp%5{l*Vkc5|B`#E6gDGp7Pjc%#(lA!Fe}a%^=a>O5zBuALX*t2-<;=n#^39c z6YxxJX-R3!zQlF&vw@!@2od@U{tUY!e_!_h5aFafBAic-C)W&muZ5ND4{aa*65-T? z06n;nX0n2~xJQITJfep?#CZR^jVBag&;l9f4ZsaV1N_s*&@V)IZzwzmL{~y>gdIPG zzUHDCOhReg0EOB}I0f=yq~h)=Y`t8Z{LJHk+@C?`cbH)K&-J*}nPyE@21D#d;8}P&U$&k?6<#b3@*_UB zx0-G6zwm$QMOc_Sl_Cv=i~YxisjasAriqqfxsC9QrlRuant!^hLt`~^S;JVR_c=iwL-52di zM13Iz6_Soc)9H-QpLcdTqt0lMj-n#1uKz1G4+ez;+>u*qo88tq(YWZww34>@11P&t zPJ!bw7zI|%uliG-+PR!ox?lG9W*>UnBY%C`zmjKVI(=)6kNGmWWRvUE(qZGf1OvI2 zzCQoKxHNh$Z>9{Mz!FEBGxp)h6cn{o2cxT>0qDD$_Z-3<369og*Bx+rEO4;HL^Nd@ zVYs-~+k+YOHc5>HK-zETi(t42QdT_-&b0UA7eI8A1zkh~2R zj5ie8(4Dh0>mH>_97Nq^M6fJ z$3v-g{w0jc`>EJlAd3d3l%#q=PoV{UlJ6DOjwf?n2zC&zk(w1FY<>;x=Hks z^C(Z7*-}v;=PQl*?>nzv#Tbc>nua$dcO($NazV9f;A|x|5H*;F^<_Ml;raX2HPm&oA}E*d zj>hGQ227XZU$WIJM9?)_kf>?{g&g33)Y=h~aI{su7Wd2dtQ%0ot{mVbT2EVV;3%1h z6j{*NJaCkhMptqI(3te}^!n+wb(!CxTl@b$o!*_!@L19zs|I33x4$~0M2Ur}V7Z0c zm2iSEpoDCEgN25coXMPOYODD*bhgt*ytIo)mwR@ojqJgp0N|6k-r$JJQm9Q8YhD=9 z*e8*;`3fsdsx`sbN+;qO`=tLlnW5BhzY;5(`||lHRZe2(p6V>I43cP_fIQ+-`@@5=6wSxHW*u4!LLE1GnqHa@kev5 zF*R)-MEky>(5n?A{pLO@8clY@*>JCZjDy!!xeRde=8kua$b&J&!x;Q2D5K|EIKhWR zTlqe1UoL?skk%{Pjs<5LC(%Ji(QVC|hX1VO#PMgSo0g`RGd?Bcf<2SD@V3qQBvCh< z;MNYQuj?!Pc#K17=Kv#Ia?SD#H^(mDunz8<(H$bBE{bT`HChxqF1R@I*4w5w5oy%% z@L^Ec2qbxRkqt*jx&`=9TnST`;V)TcCQ7Os(GcwAs_TH0zqPunLQhtt}*h}M0{ zbS;y9^#z*9#;el&#hwxLejlKpdYobTN`ctYHk%X=)o3TU!0d=Y=kbIY8EF($05|Ob zC7y0h;K3J&8`Ztnoc;1~EY6BJNIL ziMyv}esKJ=qjvSPYHnQMMG_5l878`IuCGAoJzGlEyN$CQM)O?OXnII|PJ=<3WEt@$ zRqs)5N!Peg4thU{I#!fnTYU`B$8r-|5p&|Nm{o*qS;%VQ2a zAk-v9L=PHD^fUkn7)53z(f3KCZdwWV4n^Or6((Ybp=k+Hy5LFJlzejM`{!EA7k&&l z577p!eX0YoLpUNb9}DSqf6C%C=8>OFew_>wA+i12^wkb*H~8_#e-zUQD?NMyfBnku zYTL{bBgA%J2S!_;>;>rRZYsGjqYUtu}^JiO$$!>scXF2A_cy z1`yTTd>jBcte1R9!d3^I_b?(y_0E3JZt98YEf_bh&;>0fVO(a8+GbhK{s3=To}Rtj z3$8O-A_4bhlcLU%y7b7rQ1ULx?e-=Vkmdio+GD9I`-oZ%dtTyhH1s~@q9d(nEqi#h z#%E+-%Gy+>b)dH@I6BovSu`{Z0N#Gkm&d)7(a2;|5zQfLIHcf+VsTAv{$WdKvAx@5 zU7m#fg!F87mE+m7=P;P5<#X%`H^-ZQ>~@3WZPd7xt?hk27&N4}P7%zkBI94`DB!Vd z4qKKlU^i|*NbN(MI};d1r6RS$(uD!9TT&-6W2($b)`c=(5U1La$!uzD4iygHN+zUS zbs^Wqkr;$XqD%8YdQg?r)b>ar0xMejkI5K{OW9x}UEfr*m`bz>9CeCbZb9#~v;od^ zW>3dXche_FVlU?N zA6y!6u1UjsF`T90=8mo+Bexmx3w!scsLZY z5<6K;7l;3*7=sJe12{QJ*dqMF9cakwo9 z?4QG){DT0bk&b;NRUdmf$My9qvS7`g{6XHF7{zeVfUHH=Dx`m;_ZA$%zw7K?m=p>C z7a?;he3K9LD~X=moo8gIpaR0(M7BRRqa9(e6X0L@oGe6>X_%Z;dM_0Np^PHhv=ARE zlBDawAqxoA26pDR9i>3^t%NnJBN{vm= zBzXbFC;oli`x3z0#?{*}c+Xaq_0v>r1pY^*bu$vR2}!tVf4R-mRQeMulqUsp#uVtM z75o`N+hrq6>Tk@k!PN%*AgX160mghV$M0UZv! zBa>=afe+0eh+3RetUg>eF)!p@itl|n)TF?Rqn9qb2_r+6CLv-WcWZuIeCEl`3|5g> zPX6+w*T=g8QOZH%UeFT{qc?MODAbje_UV~dk~@qwVgPVXdRiJ)orW!LlJ@2ce!G!F zIJdwh4odMH_ckrFZa#}5Crfd{&UivCSpxvyuPcU*{nq-M?gO-E)PLklUkIpWQ&SDR zJWf6xTqH_aRs#Ew8HkZFRgHTX*UKOo+oJ#o|aN!{(U#8#2`G01(q zs{ak)&k)nwe2xb$h7;uSgKq_{|07wCCqw|Fc<7#l>p@10`f3`%`De{gc?SZB-w_`2 zMH;7b*YaOT_DzbWELP<&4o|}F8R8~=8EE(DXQursXJIlV!i)z+_4NOPOD_M#C7JR6 z;*z&IIOr1aLbybE50@Cr1CoDn$FXHVKQ@9bHP`dn}a6YEci7qs!L zmbRGOHIgR>5d?lxH3|M(l^Ph9?yhWGlY~!gYylugac^v=g1uZy=)M;8y<^L?Y@N;P zTyQ1nZv&j5=6`@m&;J0EhE0sV$S0SPe}KtY*8c-c3W?Uh9x!p(111HhgMBz4L^Z~b z^0dOB(p~k*DuMq6Q~U_aSJ-6Z3WUlhlIq4VS|`jl{5mclx=7YyDMnxj7vs=U6Qamg z_^6KAQ-7d?XaVL@V;-8;ep<+*X6xY8x!@I9q5<7WSPrnQtxYlTA28XYfk`h~pgD>L zJbLsG9^vKXRaXuaCU$vn9Csj5V>-Ey!BGkQZF6@vEt1>@oEu*^qsYbE*)SwDrok1U=b4;{Uv<#k+vJV-3a3-2T8E>>Yo`%V7~EPOqN zA({JBY)(L9?kD3oxIlaJz*jnc>%>>|1Jb0R7YqNRLBs$zkr62r|JUhEFW}+2VJh~L zhR`x1w==;Qa@)!d1sB!d-{seR7a(V6hO?9#MP55|iWjz!ji3z7)KH%}0-~cxKr@Lx z2*HV%f8j)WBVzk36O)aw+V;LDtI)rS9R-Q17nA5?_8x$#KkzWhu@j+U3?6WSxvX;^ zn@1zEt)})Ukf%81-MUtV0KzM-!#W7anxEo|xGMvki|blO#L;l2iz;-?s?#fMs&V=m zqBWI)-*raN<1Z_r7~R}HfFr&?Yay+2)H9IYkOjyi5vPM|wx~&TvtjgN;VH=Un>j2^+XuKd+RJ5f zH~{SQ=^5T-8cIBv;pZEYewvA?vQjed{rbKAObs)|*{hfylm7)LzzfR=6U?Bl#dQ(* zIpnQ58&pMp9lsBNcJK;;?}iJ3XQ5fj^~X&a{r>9r*NM}JJ)x&iDd|dqm;WROCvuq% zPIN;iBkbGquX{vPnC+2WNrReXCZ?KXv`trhlcp>%@z-HB2%TtlhA6qWq7)$yqN8e& zX(};wETgwZ%RL1CC(b|?bTm=jy;kO?j!=mofGj9_hkyPrz>ol3Wi>8-#p(VJzySXO4Eg^HFote3e8fn) z_Hs>vXF&U;z!wl8hE8x>s2c;b3ap{%*4fUqLAs^8yQp5Yf{=&Z+nfQt|3@l4Nf88b z94oXHFUoUAAiRbl=0Ut|U$NL_h5*ZcQh6+sfpf@4SGg)X4&t!g*TUZSq(aJ~_s=0< z8KG(dmNAAlas4f{V4wRYwOUrfyJcpRn$Sv5TQVhhiy=`t6(}D>+(XxIMMsptY|#KL z1cM-!@FB+nt!QytC6%`w&-YpD{*a6znz{Ax+XH(r;s2t(xT+4v-rns-mKy zzKqBrM9{zNPv?RT?vc&;1a_By_VJ_t#|YXxO5-9pf+Ru6)lI5>Kxebz2d1N_x*4sw z%F)grZ8((OEG-&e=u0@+QFL4G1)qm7q%HU^<#*f5@h)QYN2fDgVKS$K(ZlEjThc0H z0kTSMTeC|C{@q2eJXEXnmS@Bj_7FnLDyLvc?vU3y3jx7EtcOU-3ctO3dxA`|C+N)p zWs)~2i)3-jBRh?39oWP8+q(KVyh*=H)U!T>It_DQH|JBto=1kWmkYZH&imeYf)RvR zUn^0X^-B3=8(k(QvETRdsK2kD4i<&PW(JW}vbDAO&N@r&OOqyr@zo!wY9jddXXb5U zv_sSH(JwefX`II0&=ZPoS6D_E3WpQAln3z;>S>Um*j)s(tug6?&T`0UF1T8tpD+@r z(W;wtg0N;_oiMD*MB(2Bi(1jINVQUWo8wj^XdRIhv_crbk^&(M;|HWZM9EQ<1_oma z+2)hrxm>Qp-=I3ZxT1lVo*Zlrrn+J9FhE82r{dTCGLexEOeqx=tFx&B*&}KiZE9ws z3bDPDE`0R}bd52L1mQokFJ<=xev-vT_aykpHOv;b?vWx-hQZchaMz3yOK*nRrBH&^ z3v2R2Vgn7^jX(I%j(CNFrcVi-1M>T(20v%ei@X3g{mm@tFNi*5oDTp`H?~ci3T9zq zGBFo+Qp!HMw<|`7sh3aGoB=yTDt(J|>fdUirQOqqJoslAj4#ELp_ND=i&~<|Nyh9? z!v7zj(f{>-0}WmXHQt)?t*g;0S__37ck`k+{1>IatMr?a(3_$Ve`6h37EUN0owl!6 z8#^g#IgE~?LVOVbJT$3oH)Q{_BGZ{T^y|NnBT*oUZ^V2<&y_TSE)$_lNugG_iOUsu zXOZn?2& zN|Z17GFr%9E(D31%R*b3HRr>ou3UPHM#SJhLttK2G$jg3N0&?P+1lEb;qhfKw5%{y zHR#F{(2@O27)2)j{UoNgy7x_jL{vdd_o&wMs1+@d6w^J)eT6;dP?5~j&K1Fz8_$=l znjF5D+@P(@pxTg?A@46jHk=SF5}@wXpDUJ!eK5NaKC5oB801-oz75Ke4$LE%|8MlAq z98kOi;-?P$Pn-ks-#CZv|A}*KIOfwr3D!`YBY!W>5de8m9hjy)a(gc<;dhO=EB*-y zP$&RHah^>L6$ssb8lHhC6p4;N7{@x?)@66W{zf(WCJz7Xz}RUwm`pgK(6OT#{mklx z>OdNuuR7p`xz8Y}HpK_{etq-j#Xpsm)jzlDCs}@O%`NIP>bMy8OluT1WcQyxHeG5l z`PzX;FPFCc{I-bxtuDDX-hlF_dV+7U>mSkjwnTDfohNpMu zdD#dTwf4V*jCs5#nr8=Nr0Q#I&AW3=s{Q!R@p@`u?^>RJ_zc4Fo5yAMia%}R{XeqG z-KX|T1V6G|UQjIz=T9E8H%jR~JKijF>8N}6q)7DC2~EArg5XmF-Glo#1uAR`$DTRQ zXesZT7uDklesUmF=+o6l&AKZ=gBGh4wG$_ZX^Xf*1>-xVM^d^h*x7rwF9+`jCy{qI zXk=$#sU7XUSxG(o(x+K;*?39o&ezdpwW37SF(i^1?3U&~aM|8ZOeumE?0x|>sbZCd zlImAjba84TDBNCB{XtN6twjAK=%&GSECO)V?A1D0bP_&-d9Qo}P!ZlMk`Fl&L3qFB z(Rc^&n3|Du0dm|`sK}?lsAY=lA<$oVtx&0GBb^xr3&qY4o+O0u4!=rVnZ}#^dFW+* z9I&<4f+~eBKn3t{vAqKL%DYfm4dTK3$4w_R?Bg*3Srx1v;MTnbbZ4_Bututa=8I_W z%@>I_^YPwm?J=>sHC3&-MoTNZ0IJDxX?T&^&~m4y=Z3MOtYI0)6^k~~{#hDE*q0`A z`NgY4K>7$53K6e*gDdz2L#t%*mel@a`aZ3|v$wc{p>nAUDY9^n-b|=S`4EPpqrj{1 z{bOI7!YnW%vsb&$mU)Jo_du1FQ^gM&vo4u%5^gB{t$9C?!?Q-Kqs?SA$jx0+@~c>= z{HD~U zmMzEAhp!yVwrYOA=4E^iSUqEdlEs=e1YVK4BYvd z0F|6wnZO`x zjpmJ50BP)#8IIgfo>2T`Gm~H?gMIH=A-1~&R$`0x?9x**E7zNMF9+!n9DACjR_YNcQ6n)t%e*xkHHeFa&Nz~9xqpPGOIRaI4I z-mh_7fvU?4pc-+CbyzRdWQH(XV))Yfv}3*;PsI}dx1ncy<>$w9_p*U)pwGa|FEfjH zk>`F7kkTQeNnHXd(1ai?5}EUue*K!Qx4}ZhL0_i5Pr1TIg$^<&je}bEXO>2yX%e#j z&BJwzForXE5yx%r!>aMzAp|$5?&AycR#!9o{R9uFy@Y+658;YKfJ%)sDcW3)%a_CA zr=p_bVy2l-llAOE*&$3%=h$!oA}GD-M}k1P$4CB*LCw}9gY?t+l zV|&HtANPvS)q^DVGPlOwua&?#;i4j06xP+8i@gAO+uXMnI+HEA{3BmdP_s z>I*TC=}P5mh87G%?qgN>E7LQtrPfP0x&GFjk3eadBjUzmP_@M4wAC}Yi}5Q6Bn_jD z7zx|@o9`JJgnM`x!!G8_aS$YkulLS3HEZ4hM5U7H;wkzHq5p2rU5SblYb^=|s{S77 z?D<$XXP~RO?M4#ASr&ZB5KF>djCwYTKG>Ok+sEvuGdCmYcToR#=TTWg>+NZIpOaPzE-X#Xsck?<7+`&H7e5R}dcH5U1R;iUdn`OkXXP&2W)>AlTuhtSWw zrTw-`eo1Fb$3lskQ395)=`v6zOw(TKW10QKVFRMn7}ULA zv#c@g>k2o3UhAiFpku+W0~i1fzQmr%M`d17xCo9ioxyl{jJd}as+`6wjqZc4gh4f@ zz;XpSghNgaCA4NZJPgp^?6GkS1u_B(!WbItwt<}@> zI~P_tV)Y-mvc@@&Jbc}E9Yx`8X3o5>9 zpQqYPb%1~hx5~Czx9zaRCH8vfqd*FsNohHYTd6W4r{_iqx2^BmNa$4sQzL-@y=wgB z0LOd4TMDZpReUI5Qpv-X!gQ`+`qOXwEARd!zFrh!v(L@7?hIZpXx*IHR8esjkkH zYsyUmln?Dv=Q~;UFN8U)I9Xjr7O??pkB%Dgi7dHS2q}Pi7pU%e2M!7u47zeA-Ifrm zwwCbes-Z{jo|+=I__bPnf1Q2AIGge7oZG^frRFflc;{xT^ zQ2H69t#fHy0u)lCbL+EkC=D+Qc=IhDjAOHJ}uaNpL}9<&4-SJ^2Bzco^-b$>DR zKnc(1_U6r4kp|)@uv7#FP-wqa#Y4b^OO)e zO$N4&6Dxn!ZnACm@RYW(jg-wqd*Mk}291rqaB~no^{Z#aOSwFa()atv#>Vv{@U%tY z+`!<%^#DF>d_+)G_ov#9l&S=rE-y({U57koeJ}{n?6tmr4``EFhq@B%x8L&F@V^mv zEQ#>EgeIOci+HJFWOV+T!N@W>t5<*|<<)QZ#XfxsDf>i>_auB5!7rySBC{O0f=C_u7_U6}0F1xvBWsXd4L=gWJM*JriU?0G#nYd4q8BEkE3{>19i%|6r zhDPvy>gnT22otdylv|{psa}Ze(yLIdc$v+22r`zJ2IYNr8l}$@aW&)F>5WtIt^6FJ z^!8!rt7`SkRMhjP6Bfnga2dCgF+l-xD)xPHRe0mNOIjL;g^sJ6%%dr-b^F0zhSSWK z?=$5n`X$U4zgLrKv(T7#{#jU9xWoMVb=a+krBmDJiklAW{@3v-mX;|kuV23Y(l=lz zNy$EjsekVb(DtIP@%o$gr!_7@S%U9ig6Z_*NsQ-t*h?YUCt&$!L{UJy2=8@}ADTWg zma$j;&jj8=W}Mx@t|0nENBA`ktRRXl4gvk5t;>>+u^!9|6O*@FFP$+tYcZ0%53o-p zTR~@1^QNVFV!$!mL9CGYH$#Eo_7)SIZVKi3(M)18eLGvkvKs$GI}@8r<2U+mzk*6}f`%a0 zVcJwZUTRP^?mVSXHxd~6n~o`XaW5UyUzoIfkrS@_HytwrQlo3Ijm4i_2YXwJRa7)XFg8c7x70o^m$KH@4a=y9Ou@?h1&SgDn&R|N+E(osNCQc?*!St|Xl z_Ji}}t=Lrj^;-!9rAyFwEG{8U&ENA9k~Z^}8__N{cV^jtvY(rMU%Ves|I(M_cf!Nt zPoPZ$HiS5LN&8DE7p_Z!D|o-Xm{6mMwGL z??rezhlY`=>DYtz1m@rsg{Pn}*Mb2G4Ej$K22tJ3*N}%zggWX$2042pnf~DA@GjgW zmM?Sldi`?_Amc~)dV}Tq{p+IupCV`dE{kTn<*#o)gi?(isz?TsS8%T-sCu*KbKDV^ z?jHg*tEw3;6P{il$+^fa&d!C9yg$8#ETH7nviiouJdfDa@?csbIgs7&fohd`DoYB< z7I2W$?UsopK&rvLUCUysd>QrYWbN(mhrS!0JBrRZgjv!aV1N9bD?`wX^??kvuEsUt zs12e->fTu2!JZSuhzXNm*dGk=z}i72?9&#f+wV8b)ZhO8fx*GQ^w&qWYinzLSGT$e zTjO}``k>RWN7hz56^!-bn->D6S|`RMYR|$~e@9-bpHM|{C2sz{YWWDIsx}c&FG+5} z-^c0q5cDUEKT!XiPN7Gpe+wU4=BID|7~`$peKb8SgEFakU$M2(RaEJ?RWa~{Zh#dk z5`4(1`*yQ5-$QpZ-5^f+wn&o8K}^c_VPPYd?cA{PQ-!-?!lcS3?8$&IGS8th#-q*d z`Eob8=$S4$`O|T!(q(d~wepKGJ4&M65ZKiD=Fiic$2-pM|DgEc!X#dlIczcy4S0-F zhbwV>NpYv(k$h~x>WA=WzYi;M&tn2#sv*LI(&4N5rZLN(BSyj|w_?rW&YerxK*571 zR=?MHQGYY=A}Ha-YctdP9Ck(m&-m)ke0Nk{)_Y7x@0NJ?ZWqK_&pyu-R~tHTcG2Tq zk*cMl?rv`5;B?%CRr)4-#}N%=WtuDbF+rV*E8{F$3eo*?@vWs4)8fti{*9Wa0zM4e z*m9@ipql#O`bQqASO?g?)?miuzT2>>ZB|Zre-yzk6%pmP?ES9DIoRrsC`CbH~n0@5d1Xv_M;NeR2Wv+X<&kJBpU#%nZ9)XDf1l?W04* zLGfHC0AFD+caDav-)#uizR z={!e`eNq_1LebXIR&PI<^D3k;tfS1G`A4$&Lc1OpV0hP5)N6Ba868ZZo{vvHP`w!Q z6kFG9dG0q-uEo{<(FXbmb2Ix_y&`yn!&u|-=@a#=ZcC&eP8)YQiiwhZZZe-b&EHuc zfs(FIKXW_ko8AA|?sCTg_;Z)x&mYb{`E?9X549TQ0?1}bEZ5tlFIUsCN00Iax>dE- zbO~;sCmK}@^(LD8JYn%vDNBBWtyO7Zv2d4G5fa#5=;bN5k^O12qr5oN=T&l;yi*r9 z%2|)of9B!mJd=-tXA?&2M0n$Vs0{DF-&?V1oBD))+H6^r(4;)kNw%|&tU%3cbXr8W)@!4QXQ`w6)Z4PE4;6i9+2l(LGp zs=GQjtzbS2eT>ouqVEAUZMw4;cLrV%b*cH#YbK*iZBj_jfGBlbCp;q74$DP36{^4f zQ=hS_prgI0DmZj2YU=T)eW96E``&aYET26}ZZ#LlU|j!j$Tp>F;Z&YyzHx|<)M}Z< znM0t$aDlyCqyb0q#p&4%&z~C_RgJel|NNNOkb5u-@6^_1l8=^D^L89`O#|>xZ*E@7 z!l?Om_Ctv~yn?(s;8q=T{qxt8DU)13<3s{$*sg8DbDzSMfMNp|VwlH6!Vy!y0UmR! zgF4_K=CIlYhly-)`0DOY6PFpiklO(pkF5_RN>$MB}%UR;<8{rkHCA^CpwKQMuyE7_aY>3T3%0CI?LK zNsgW`5#YNUP-(A0$=TqvW~Oot4(mloUgiK)cA|Wa>fhe})=1u0a7n2*zxLg&`ez3A z&^TeO9uXhg49V|@0epdw;_y4Zb6I!p=R8R692r_b3#3%02&gzFNksG!i(U7mCcx9R z&&*onb_}x}0&^{t{_H7T%a@HnxjV&lzj%ftR%*&`SuAO%_^v(X3;zS}ZwHlrojR_0 z=~qxt8)h0KFZ{6*sI7tc-y?b68b{p9B*6ND+T6DlG^Koqz zORXnu<=ZAJrj66WOaFt%tRgputiqiNHC4>Kk5kiGV1bh;a!|IwS4qzPJo4lzm}1`C ztbb*4QD4a$AG@vY3mBC%r$?`f&zq*R18&c-rrInP2_ZQ*s@Yr4z`wZvxHRBwlyJ%I zo(C;iB2)K*XT4UUC7D@^=dUPo_IAJiSd{-`q-8V_1X)Sej(QC$N2v$s|n(ogm12db!%LATHYU914Lk zO^@$536ws!i20K+-S)Vlqwt%I(;=`O*>||_YSWsmpOfPi7k57%3~bkdw?rk@UN>ot zx)3!lx4)C^654lRQeAGDyE*mC{s=JqOlN4!tFN+GHklfCAD8$SSkFE#bNAp2dQ9KV zNOvVwOPTO(7cl_3Kxn-gu5YMOEO%6x2)iDXINnyUg`_BkwAd7pDzO2mt?~k%?0sCSGaf#$D)>joRg1!^W zn-LA`XEinBsPC=E7jc+Vk zaH~LJ3_d+6d+HGD)O*zFuWvts$dlK|eUz!MCOJmU`g{VE7x%qUGk=ctK1q9D{zUNZ zSoVz9E5J@LL4AWoKxI|_44}$^UX=5WTWloNomyiau>OCHliG z{==O+&|NtYCrIy&?Lx(JH~NW8<4b?yq#7DtNWmZCr-+o}V-5Q$C$EOAj{1z2(72D+ zr@S-0uIGJNKqU;OG|7Xo7aX~ku`+2qVOw}j%|tUn0cuReeq=hY$;sT@+BnB5ntbFM zS^pLjE|ZVm%nBGqb*zhF2G6`%$`U(ecv&kqNS#WamCnI&~KRDR#`sT(PRQBeqtTX)NU?Ex5>g!S`!| zNd1NaA4_-1yo%}*x^L(3s^$$){pKb={~d>OKfNOoV?JA$00E45Ym57DlI!>F_c9pF zxTaXM@|LDK%@L+4Kr!q~R*|Sy{B`n}SDe5_4nR{*`)ud1vsL@ZjFn6)o0_7B3m_z@ z^7_d8=RNXwgRC!*HBx2-Ra%7xxbsB=>eP;5j=aDr;oe;`)Y)I7VRY9}%8V{9NF~2} z8Gl5tC58?CDQo(Pa^bGaK~TUnz{cd`_v(_;Qlo;LcBWB&Va)*^tgVwaxx{M!8%lrs zS)eZHvMcrX8NqGMv~|OCs9@f1hTqmorW!F$w;wHxnTa9@ILwqS4t`vGGp`yf!(Z}H zeh97gT|`}hfB%JTZTZ4q%a*qGJ{(BWZoINuuZeI*vG0qe<3?T1y@CBwN&)X&}ITpM{V<^`8z{pi^dSSkB2yZl9%uHDeg$t-R~SZ3-n-1E}bm3W1O}b`DlG@;7LTRZUpfts6OVx zHYU<$$b2-HOEDhc)aM4k!$ChV_s#_@J6l?0rf+3Enx)-@yV*2WZ_(3vs3RCG+e z0CDu%XKL~bdVKhV>@NX~iY)!k9a-J{h!DqQtr<2dE?j_ee385~a8POR=wNgkzUmuG z$$02io~7o4OYIsPH;?ctQ=%*V*2Q1YS9_7)?jHs%Qt8{b8wvUN*tnq7Ma)DsU&2}@ zPq5n#{ENfnD;N-Z3=8(g9%CGndfd_*yF8N`)m)&@ zMcb;-j$L-Ti0K#*u*@!<5xYBkJ&_3*!PKY;nDE2_4mz#WhFjuGL@6gi-EHzEJ%P>UUv>_cUCVwID9d{kYbpmT8`mYZ$< z=g(8}5VrU-6EpepRrD>{9qM-n=vG|f1Hvgr$eGW{wasF%k7ob+3%t(oAUySL;LIqb^jgC0F$)7Bq$!l`DULRNLd#KkQVK)_a9N z$Cujw45`!)C=|&>iRPxhcZ=&= zQE)q%(AZ~#xiNIjPWsom)~^WDeL|T26V&Kip-~2>GKsNPgfilCyvEZkMuryRLSN9w zuu#+!q>lf&K;r$9fN{0?PNj=N=*<`z+TxYFOA}&5x%r(RY4bs8L67HO$#<@-=>3I1nO#;__JTUgFwwm zimGk)y8aM4psjTAX?#=yx$%b1wx|$nmq+Yk<0)E{*Td=%Q zXnJc^N)MWr34<~8f+o4y^kAdL?G6(WB#3IfZ+1)StY<{S?oIUN zTO%|Xoxj~hz@M2a15+1x2*QamG28OHd4tJuFV>o2BN4&mcfCc`JOySR_q@JYMq}rx zL1q=RLig6x?$zM@9p;0x?GillbCVCh7^JR~?$oQ@>wDQ^U8)8}zykg9BP#agdLuq` zvtpI7Yst}pNB$)In~#BFw$^sN&qKV65#TnNn2ISx2dF_wp%je-$N;>@VD8}KLchN z9tOF*^x$tMKT2%<4D@4DN-Q0}+qzFTtpVV(@LT%wnEUIQde?I^E4p8YH}82d{!^VZ zC3#^yF5`?#M^_-m^nMs8*QNn5wQnjfn4!sPa|-Nl|K=byedhtI$~pSA!Z==dDEZe5 zrzaTV6)&grv1S zG9oX%Adfzjy_^RT!g^PAnpQ<#pqEXQy;73Gcu-G2HBA3Wa~+w90&XEi9Bge~8>mqp3X?W*fTI zp^4ztqXN-WJDh^ZiTa=w#AA{`4bk$4xhk}_qyC3nd`zfTCE zxT|+NKmN~&71H8UdR9O?n$fd#;<|rEx{O=*x7x z19vO|$0FhexqjWBC>>){YAkHrVw>RUha_51&|yvp_6@qSyaWT86+4EnhL8LY-rIF>?TGND+_ z@b01U7OHx_fBk^lmm9Sk!X`uh47OjK6@9K`+xyQyJ`v+6^vp~}!O%yuS!bz^BPem) zP5A`3+(&{{-?h9%Vwa_#=v?O^6mrY0JKGDOn_1*l(F^5|5_lxkuNc{w|VWt?T zENjoJEhg5htTV?~0DZYFgUtJ1M-|TYj_&KnawhA>>^?M}T)kxo$2Y6TRJQdsx!vcV zPx@HJrKn`O1e3_A2KzY+p-w3e6^5?zY6-9KK<+ZjRR-%9ri4G(7dMP)cfZ7^Wgcj3$EPG|r}p8;A4GBr3G&lL@b`a($7wURZ%#yHdfV+pDN3){_@Afi_u5qZ91QHD7=1 z#>t#!3BLfIy@C&i`Tm42!)SdRPzZ~^{)+6Ag>1muOj|oaCO#x<74K(WfW{MOR4ONT zs=tR|yw)DE`oqNUcNz#bHE5`TYY$}4a4dF6-jsf@4I8FV2C_88o1$V2NJr0IAa)AakFo;jYe^m{)-MHKHdj3rs zmCr99QHJ-9>h~(CjUtN=Y7M3>qem0_n@cQX75lYJ654+)?U)>_n51Op|JjY_Z1zo5 zopCm>6jW(FRbU!2F5Sh?%?R)a{$m4rzJ4riN~Kh#PdZ0mQbQ5>OK@>vwLR#v7UA|; z{EBSLc-Tks_`|wqw`#Q9;YQJsIs*nRFi*o{m|iq0T?OEOINycYX@6kdW-LaZcU6U5 zLC+*PzZgxPNhe?7CKgApBC^v-w$z#5pgVATRG0~H+_kE)5t`MP9J*S$bJOPctI-5#oXFL6FdeV{iCA=Re6I>Ccvz>%Qd;FTQ z6+?^XiCkl})rkUQ^|}oUG?>wF4(GhZ#C;P{0q~Jue6F(ecc<#o*{3b98(c$pMpc&jvLo-NT z_*A!+YHy*>PcEElU`sJ?At(AsZ?bv1!2U*$e7Fp@0uo9I2%gdW8HslkD-G7Gfb*S1 zg6uBK`qMNihC$3pFCS*Q|D(UHGG?xie_G3+Pome&MR`1@Q{K+@ZmDCU&t_{hjMVA- zqghVS_@%bMCRua&;i70QC9(o^4x1sNeJ$B+w7+ECy*Qsg!)pl<^Ul_?)X3K#(oY}H z*^jrJ2G5#rnens*`+0v^lb_zB$^7#SKh)i0xc?)XI!cbNE0!HQ%w98X7E32I313}s zA2FEf3eD9rrg~55j(_&8@F`9D<2Lt*Vh7>ppm^wR?D7qw#nvCuFQlh@_QFKz!5K-x ziy55gTp+~y2_yEhRq_F7PN$G^Fi@92U@Daj(G=2r|isL&X*t0^38HQo(cjNu?9*8kkOTru?V z?=5Y~;|JiBy{F%~`bON1d>#AK)GJ{b1ht$v7-^{@As{{=4@q z)q%*Ou<*g@Kko(y)~9W@TA>5B0|WCroDBXC<$+hf18(UsR@1+8%AVR28BC4-wa?-$ zC|W2QPZdi(^gVj?2@FU!MmFHXI|KT6w~H1pA6=;^URskiAE@$w&U^j-{eV{$M~mRJ zwVx^C!2fZ;)*BE#W)e_2Oe;UJOCGqR82QZhxX8z=YQ5NYPJlEUaFlc~Kb~`WA7g&Q zWpSefN_ZKkeH%Nr+1OE^IQeLlFRi2Oc(Z;Eyf~M1+rL&ed22jhc9Oakmf%m!HzM=;X?>ApI zypA6lXN;=&I#B2Q%3(mb+svRyYUIvnzV^U)fGymyY}ck?pEA{D_h;e8_|Ea-QEx06 z{4vd|4c=m$5{!y@SWxWBuz|k(aK>g(m7-Z4{sXh%JNrp}+y|o!*ywiUT*h`T+Q>MA z7hJP>Qe8S6&UwfI*uUFQ@!VIyW*>#@wddT%5hLv*Oo#w?Kz%-orfb2M5U6X1FUU! z7-BISXmIygXlC&+8tE{w1IU>qGECVO4;w19%C`Yo1v4=Qnpq^FDe{mE!N2WwWt#SON~hkc=39XG8x zqNo1CX&|$hY)_@J?VjE6^7~6}&IR=&&&6aaS(|$R@7>!OaiSHAGUD27I@it7>3ygT z6$~szAX-6MT2k^u#bRY-XcyuLzHNw)?*fO^h^`FNxt>xINurGQ1dW&$bgsj?gv(t} z&efN^gl}tCMk;n(@W*%E1yR?*tAQB@b<+Y=_W3&Q_Ks8lkPAJ3FWabunKcF@RjkSDh1CYey zx{}+}q50iAJwqjfjE-@xr5VZ(TSly*ZK#4FWJ*I?Lpt!*xv;(nNG`Tp*XKQe>XP@N zKCio9|4A9v#>Vj3UL@%{-S<*98BidR1h&U)O$WSluq_58Bj_DRR;w$Mqwin%tpw&c z1&2OVGFnf2#s=9F74s3@bLK8+tRe?;LoMS5seVlH$>)m@-d*_j>)3d}LHzPHxPAG0 zuMjv!`K(nboQ-?IkY%S`aK{BXLZz5AIaC+Z){nf_gC&Sv(}nR|eh29~`+NlNw}*(s zMN+p%Es^gP&^RrbDvW`ZYE{OP6@&iMs(bc$@{G4F{QSTD_Uw8FTb#L6COKm11A3Ks zQX|+=07Q0nYeGfpI~|6_+Vml_t;9!%*>K9OQT|7w*~jlvBD*&lz>y*l6(Y%A~mh9V&j67artli6y!}sR){14 zJ!u>!8|wUzlOM5<<1!>&8S;FY2^#4hyJjV?JwC^Yilk8={DD`Z%gj}I4*-s-A$dtw z2VXt{@$+kd-+Y#q6@Ch8RJZ`Cjhp&e+pk2jS)8AGKdRW=BNL+odw-^}Zfdv?t*z^2 zL`EzaNDj=rB|n2j?V(K0QP#s=PLrf77LTa)tLSi;EbIPh8?V?9%(CGSO=E9{9N32U z+x#76eSRo^s3Wb~YP8j^hBai~Gp3UYTEG8Lw^Mar<Kl7P76Cqh5TVJnI z5AgXEDIjwf^Bgej5O~HyXHRL>(XH%)Ia-s9GK+Zp$*j0bitk9esWgouuYkNrJ51zT zvSXfad|Nx|*~u>~WGIxkwX4+xv2W94*6F^saKB8&4ccF0i41%spDt{_5mwpmN&V?i z2t?@v%?pThyh6tUVv)o()23dErEtjOwFFz^bTVSTm+}jlk`ZHS{e2<`gVD{?E>eYP z=Mred+F@#mG-VhkUk;kT`Peu=E90Wq^H9QL$B&yM$F_tz=Ae1clK-cn`n;gbX(-8w z?E36)ZtHG*m?9m}*`YjgJT_@^={?uSIr(nQiJsExhqXWPFDadsz^QB+3v zYl99yVIA0_UIRUHdX+iYOAZezUjrUcIhNN0tzu2k3$HzgOU=?e{-S1J?auW|@`~$i zTfyNt9WPvAf#JMls=pzyS-jr+37bsQsH+PTN*L^zJNJ9(Tl>9iGmE8z`K_}5eCzq> zQzxN&{6}nbHK?{0yn?}X=8b8R9sg)T?~4XOf@-Sp57ukFFD>5N zK+Bvx7t5u;kHS0H4&N)aA4$gfr6CSn9ds)zuvgkMQ`0}b!1bOMM~<|qC*ix;{KpD1 z|MaF~G}j(QFIQa8)(;)$FTjP`roF(aDnqH}LeVfDf=2WcudFRJ^mjt8+4_WVXW#UU z`hglgLix|nR;irK6>SfhGl%@l&QS%T)#2JK9MriOS>wd+*~>2Qp{TqXJwo^+peG_u zK`wUz(FPHaP#4xHZD1)PvO2D~kIYB`4dO~7jiNi+TeDoFaA3lwP}|h`el<-B;B)^i zJOwCwxSPzrFpx|R6in0Hj(iD}d^Ufp=>_>LjSCOrPUM=CKcJ|(&mOgQQ3jc56V3Sr zdxjnE6JlczEi<;XaJlR9u7?N-^)gVL(b<+Y!m%>k+?P+?D+W#Zbo;u`un;IMD6e>9 z3K1v9XavaD7;!?M$_FLubD!puf2`*51TtO3o(DM(gK$`}Uiz~5JeZ;q1 z4%7m4ie8IU8nf$g3yz8)N4nH=z(tZYgMWSbE>vHfV`}~`DBZM*SXS_Q`Syfg)PR(W zd(0Z!E&?FR#kU-ic+6&I{y11^#A7sJ5`1awaLtb2CR<#Uug~mo)ekb89;cEKA6{X3 z(%``kUVVf4_P5@oi+zv){eqzCx1s3s{ivV#!`oy8^x4n~Lq#R14uJSBFI*b*Z_j~t z{wBSClqzW@A)S1G5Jk}pg&X&3u<1bMs7$L-=}-8sh&P40KF9J6i^WOGfrNMrBe0o0 zGvnd$=+QUHYgH5)0?q$i^;bVL>gZ_^-p;tYh=qwsvP-Qp4H*yXc5GOvDhtULz;h3RTTBo46yP_dh`-JzKX|+KWM=Q`8~OMMOAGrM2;yvNQD+vcfCG# z-Ra=W#Fh$;fAVI4#gRAxbbl+?Kbu}SdCAB{rw?(f0J~KPrONg*fJz}*T*#Fo0?uW~Wn1}eVw7*bFd@zMh*EA@x*Isx%q!Cd+2u_wyRY;*f|>MZK-ntnQ& zPOPJw1KC%&2VsM)>QF~QgiYY_d7O)ZbWi@|2Eud{MGwhSbpHoniX~`k>fbnc7xLi* zz}#yw^mJLnR$P~-l|Nu?RBJ9+)QQFpliHV;)v^TCj{n-rhpF&aWcQ5T;8Xws@rc$TiZS7(fE!AYZSyBaV|mhQOlpFypS~` z5Q&DBpI9>?ny(ldDc4#2YYTLBEA0TGxMjNmNGFOUtCbAR5!4q+6Bp9S1y{47#O;$z z-efjhJ%m4?g?*p|W%N44{M;5*P9fVuTH46}t6<6@E0`9ADFvL% zq1T^-fEw>Bob0wcgYgt>?izTpyOWi=jvGhj6M}N#4OJ`C486l*IAC(JvOh900snC@ z|Ls2T3OJ4KFRWz(Q!>dGKg6ts4^sJnCBrJp9~CgYBf9*nc*kWd#UCdNSTQV;#Q2UE-&A0443{lXv!fe;wQ4qW%ix)-sxVc>pi-AV;i8p$S7l6AX)@lGl~Q<77Jp{glS_63 zNqJ@;jf9PB%v+hj=;77YtC%o%P$5P}`+tewBjRD$i4wB0Vc8Fp#pQ5R^yVE9lcEoQRcmNE0R@mfP?kcL7DtMSbxhlojJmkLnY6PIAjCZJ zggW;`d(Gj5tt)_pZ~q}K9c|kkB8fSWSZNCgi6OjW_yGWt$_|=gl(}|+_pe0l&X&{^o6|P1lO5w-nyl^@lXv=wt)KLz|qpJ)j2Ppry z0ea{ih$36elm%h`$}?%GN`24;aw9iS!+s5P=F!;30_IuY2P`EyPrb`Vm?y0G^lrSftFKiS9I_c>A^vRCpWa{?A zz&O$b-3-KW{mvw_o&XhuW-R1(N?apY6eaM->7>OJmVOd~KRj6ArZ!B}nRegXm8>ydS8&v-t z-T8?9Vs=R9+^+3`EHw=kEN~k?Ua0^A1!C?D-99aVx(!RE=z3ag#~?vH!WM?T)}bpK zr{j*x$ufinG`}Z6@L%t(f{bFt@{K1lbC9JOIz`k97q?6*?$5sy)%(EYBX0^h!J=d+ z;XbsG`A42hfg;xb#`VcZA(INrBrkV{zG2g}G$?Q_u1{r~`u_bNLsC=oe+oOQ{p5TP=>L23uOas3i`F{Dki}(f^wWr`X&aQ3 zR|Y-2z-tYM1ulw-GrmgcdWCa%_?WNHmBE_=nUQgj8CibaXFwi=JiXDl=M0W8(j73_ z5TacEU8?hm^z9C=3sVrV%s&*)g=wU(Q| z=A+FSn@#kaZ*I+h4^wsr_N5edt+{v(t9H-UEBM>{`?^obo!9%5(!kN#si@UI^f#^f zulnmvex0yvxE{qkX2Be;yT$N*bQ9i%eeIgL021qdynp4HT~(9Cue$wdZKFTCS#GK~ zH|bT@;nU2Nzo?Y{#>>ALRkwFUfc;v^A9{GnZfmzVk z8n?Oo#Cpl?e~n9(*zaPH5Nzj{astlyNIdqfvqpIhpSZGwaO}Uc@oyxv>uciT=HnMD zOue7n{sg=#ndR;2`@29nz9JZoQQ-y-Z=C^l3J^YP2%Rjt?*6}}ySvVNyT6)!1c3m_ zYa@SFIdF$Z%}>{;8-ElMrL(|x-q!_3P>jtIAAqyD6b#*dY=E$ZJ_PSl8)tnbsL-*Qda{@`J(YayeY_43(Lm= z%NxlgOPuI7uFf_yzn(e^b7ZfF#hf&V39(<{@O|?9n;wKy)J+8#&X3u5*76E?#MbZx zjlUj{Yq9Bm=Pv#{Bd!a%Z@E0~z$Mo%C1w@BFt9pJ9Vi`Wm*CvS8$=e=8ew7$Uu z%4d5QY}>zx+;Cwix95qWr}*H={J zgQSnre`^3}{$7*#juu}iyu)H!hW8t{1xiM$HQ+4%C(#DUIKC@<;XBXq?+b*MW3B!v zLazGpS=@5g)F^5@1@~m~f`;X=sIV^52-AJij8?SL{U4&-#5Z20(2`1$K6vOJNf`*)-CpKJ28 zkUvQcc6Uj`H3PH+!0~CDJTv@#FFz346+wXLGK4eRTYR$gt((appFg?LemEVsi7#i6 z#zOhl`?CCMZbtxdyJu|~kv7qMPMWmHQ?4qsBIyiO$#)8D5fw# zgYybl_O-KFZg#HqKUXOk2HjWwlX3-wPuLirr7^K4-O3h}$=HcuHQM(pw_wpkyh+zf zUkqMj&KHeg_tMp=6ux@l&VhAWhnIKEfJuGZo0x(i{P+XGG%>hLnL9()i8J~Y6M?C@ z_&0jHzqk@cNX?eBR(oUh>g++V&^G{MDWza=8wkT zlRz+-FpJ&P$l_*y9}^oAVKnioLA6I=e$3R9KZ&by?y>s{Px+#!_NRh}OSv=0yIHka zeS=bG!5cU~QeLMXE#OBy`g=IRcq8Gkf@iBAB^;nZ0e zQnTBgwY4-_aldqg8&0SZMn_v~UqcAL?ps&-LWPLpg5Gd5tw27+Plbo3TVDnD^}Pj5 zl`3gjpguOF|G7*hwR1x`(On|e{Kr<^Kc-2efxn#I>EDDmiQ8M=^9u3Fn$=U@&J^ey z6!ovUgEUP!_Pr9D@pjYuj}ymPkcqjZL7nJ7u)oNH4!i&Ep>07m@&2}lA|5(=yS3jC zV)+YSFJcRj%-^bbJ=l?LX9CB*^;E54?{6*T>AvOU$;Moz%?*4c`u&s*wQb4MshN8D zkFcqbn0S(ZxEg3467$38(0*WDKjaQ$;E{`s|eM>mn`DgEviAorn~67}L`y`f~67Xc#8V1z1qMw*hLI%%rZF>nT+RleL3x z=(LjbGJ-KIp^!uXZVg*G2Sx#ffl!EZ-*+)ulg``M(hr0~9smk8fS8^DT!54V`Z9E3tGURkxTvNDf5M|Y(CAM-)|6&VvD$; zaT-isJqsLre3+T>gGN>SVRj(qJ6iEd3UrA0CcT~Pr7j1w=Jur>*7s)H28#6>Kh|*~ z2QMu>iD-`#E$Ogw5fW1%HgI6z`k+Fv-u5FQ{NbObwBCc=_03*WR`o1JuFve+t>NoO zU7banEuAu}B~;M95q&-Q^gES{#v@HnYi+-Vj$Ds**-hjm-6XEuxE)D-q5Z;Z&-#dv z826FjFXlhWuLgec|3kn&m9x{l7(`~aKps_$zv<9a;EhGDE(TZc)OdZ=61?9(AiaA* zd#zcSG3omMwNCgl*h8R&OC%h|iyVBO;jJ*7KdI|G`8GO6xTz`TJBp?(Z=RP`VWYPF z3NiW&h>^vFYQgO#k`%(m9jMAsw^Gw~R)WlC!%!`yuGF&wIKu zJUb?!z(>3F$IBf%XO8vXqt7g^6^4Dc2SgheB~rRs)b|k_?MeAb+9Q5m{Bbpj<$HG? z{qcAz43qU)wXMB(7pI+6_d}@_ozug07NiAO(8AEg`)%<+pc4YmS#B7xxQ|xPE-2{q z7hq=s<2x*Zqhp>r>Wn*Rh)XJP5>dVv8#NpdJ{>)_k52oB@_`;67sr}buPIed8jXwl zs@4$O+Rp8e=xO2N#cW-JFsdc-0GCEZr2p}br6pB$BPNfa-TI5F#h*02Q+EE+`R_-+ zdG-q}-zIH{nh`bi_h>D5Xn`IEi#q{Y#0iRH1_Y{xsYp@8uC8YB`du?e&H2v zwLbzc0kAJ6dmSAv^iah)i;vMURYXA_>f7KMaA|1?WI z9-LS|m+{3ZHO;z4N9Bf^*BUBb=a#n}vDoyrIPYlUn2h6tr=0@YvO9l$oknkv3AXpPN`bdf1y)TGiG%tO1(h^+4nEKxZPe&RGyo z2eM^LG=Ys=vZo==&V2v!1eoXXmxOdB-X~pas zy3x*qQ=rPJs$f2pES1*f)Xa4bj4s~M&HR-1%B|-bEeHj!vtV|)q2)eIY5NeWU8~@C zkn9YQnl8^Gu+XxY_EZcgFMmcijyed9DKqDUhY&Q9Bj78)m$`ED;PQhC$~Szh{7<<6 zrLd@3G3A7qD%bI-ho+vB=CfH#jOgQyrRs}tHk|;9C^sgnO!4NnkxcTtqeum@mfy(= z>+yb`-1`LuDlF(M!8(29wcJp0T=D8o5T+~;>*;+RZCpSvI~}hcMt)vla&B;<{w8q{ zkgpFpZHpOZZj>U1*3jc!QtbA}V!M9J+zu8PjozVs##o!F`qL-{!!l1oKQi44I9Ba) zs2xLb1rs#>vUZ>{scaNqKAZ36oG==9J7KxDFMAHecBrj7Z1LFd zc%ALr*L}?xQ^`KV>+bHjJIG?SZ13K0t8lASvSnK)>F8jaxPrp8kdHYzggT6R#Vw)} zN`nB)lMte)ZST@7EJE!Mwb*7X@+>)fm|sEJl$(t0{e#< zULuP1{hB+QJQC?k97laAdR=9P{+>z}cZ;cvY4+xHgA28Wzgv4*R8O)EjW#t>>*pw-wMZSz$+=Bzst=FNu)p}? z0X_OrU_Q%OJayO}eHdAHw6Dmn6b4ucn12aQ>S=n)HYV&0eZ0450rg<=Gqv2}^_rEg z2Ser*?VyX)fb6Lds+9U0-{DWZa;%zUoLxgQCOrF#pA5FJe{hr+Yp@)6A3OfViM$hQ z_$DdO7~Ds}=B}WVz|;531|nM*iZ!O1k%XwicY$Q{t8t4VYqVnz-RryypP%iYxw&aK zQr>jLQF_76Db2M`*3^r$t*dhB&X;DL=HDU*o*!R@SD6ke#9D36Hi_T!T9v(dez^`B z7GY#d)D&;kTDG0ktk`KzxSOY^@4+crI8TC3Z2eakVE%K#p42XyT0w8ue86WMg}96F zVgeaBLY$ZuDa7tqEJ;=bi#?oz-1QlM`IyTF zTiNf{&WsNxGl7%APgRw+->OL4kiW(INfX7`nCUKCrL`kaYy^YXAw zbSLkMinK~PAMP1DkNme!KV5@*b?}L4m6t)bwm>ouX<(4$+_RG1@DXGfnNdSK_`X+0 z(a44O-L7l6C@!KGW&6b!2P3sWjQQl5v9pMu-)tvhc})2d-t#!0+85{dtXDBlx|ttj zUT=6w6sGHyh3Z_lADN5IuwydIb=46uJ3b$k$mp@mjoy^&D`yUHmpcWv9+!E$in^xT zbF?qzSm0%g^kiK|1SI&MR>gK1U#FN97qhov3&k!`>2RJ^=uNinrV%k zvcZdCuROczd8QZ|3q;?NCC(uQJY@!PdJnk?xkF&JixcOgF#8yk+G-JL{s8hCJ^y@iK7Z+R1D5!+Ck{ zv3D_9+O6uwq?~s#AJBwW2U!BmQ)y$vcA~mqP*z%_+iQhXx;*u=Qup2>6yy>7cr!hW zDn5mFvk+_~hE0=yU8SltZG|6JiY?yHY{#Oc}Odx9C!GIEinZ)&Z_ex3bC&{@#2SC$53NDDKF)iKlk z=VyW7#@fRuV@~1fV6sSIgnhva8bkZ6?K~dS)r2NFF`Gr`l*`)HVpXpPXFgw|c4jS` z>U*HH{rNT(qUanq!9wYSG(VR5k83ABSf2Ue>fJ;sNR!eS1MBmPD|Dy@yw;lR zni+ggM-0(qvjJ18MfGB4<*HXv$5TO5%9V!jb-EzIf81q*Cr`g%+I^(rRa#hqMVE9h zy@vYfZfudQ=lGAs^3rp9^l!Q!D@l+26RL^taH+KT?#L-y#;jDC;7pc&dR&<(m|<;=}U!Gk>l#hAYHq{|ygI-bv}9fWt;p9W1k z@Q3@2KpZC#uhZ&)8;s+AfHn6_;mZwXII8hKKo!+;Ak1SeLh%=oHevZal zp6Nos&8&ISM8SCTw+f)^ldVZy+@WAroT%+U6*F2~orHYH*^p6nxEi(^Wz`a{#=F#* zWLvRdhuc?w`2EkEo>XMrsFGoA4vQtv&{k4-)}i60*DEH+khT$TRX%rH`OBEKmRH?1 zN28=uZ9B@;7J9A>tFH$xTh?HkW=>zeOl?rl! zrd)JFMFO+M&X;U`|40pj<>kTDpySsnbL$N0-mdDQ&L#&+L+kbXOXj!TTQrauT&9b2 zc-K8^{t-6nO)B~@&QS6jGqDgei%&-v1e~ZZ|6o$ojLlftdazQy{xqM=w0f2rF@FQR zhAX{&9yoQXOCrHQ$D{Niw`(x@MwmA1VXO`)HdEc`&HU$GB-Z^c1q)C|6q?=U>W-JX)PtP=FqJ*wYDa^k^Fd zqbv#c`+LUwd(z!W(unF&Mh=C%C&M0}@oqGGtM42gt^E_gD9fs4nhB7@G|VACUZa_- zk2B1wwlagZ`_vo3>pdCoa!|o~=1VcZZv~PY#|TyYAgbYk7eiK@j?lql(!^L@gC>ZQ zeFL|HSqYf}Sd7`&vvP9O#al1P7dsL}y%=<-)VH`uc)y4;tXae9C^AAO0{ApN)81Hr zGHQ0uD1ce=EbzwIyP&1$?X`7Hk?qMU?Dk6_lt>c5S- zldb5#9_@YA)Y_9Pg@4-Kj^#}zVf)s8fDtt~O?4rJjyEdyzBZf5FX=eW?JJ><`+)~s z_@(u$Byq{-;{yEu*!xP7cW$kGHkS&%UXjL4Pzu*mi?1u`xkMBwxzT}r65&?Jy+Ose z#rOsl6thl!APv{38F>&C6J>Tcn!dG8U0 zj^t|Xc>lrGE~7Y<+bmo$@dD3wp>gk!!^&sl`nweyZ z+{ciHE34OSG;e2Jo3nxwu8ZM6AiGuAeF7H#epQ!k<{(ELy-@pI?l@Q~ZsyKhF@O^wOa%y~SG0>$PpwA8OAU^7Y(4pl?A>#fk^&i5LK@LEBgm-{iKrC#u(~UXC5Rq9<(+ z+kI{u6jNfYzR4#4cl-MG;$gB7XkA)ybN^ zQQ=fa_aR&ZJRJBl(}>~mLB%f1W)VK~3qRhbyzdSq~8x+E=CujSu%i-2=TY zdnbMvx|8ycY{xsR7kL+4I~MUI?+p=)uLvxC!NlLp72ya zKhIdYXHj+x21P{|h^|Yoy|i8<|FNIEe!G5t$EDwuziJF$fG%)eHU`GSSl-}jmWq}9 zHH~I)jaDz?)5MPf;STi5FMj z8Q_)H7eOBlO|C`#sMnIl|K!WnEhpfUjYe% z`%5K_BPLEywr1q?WBMARI7{ro+Os2(2Y#i)Q!`;_Kt+*Y@_uLcbkZv>KduWQeZ7B3 zi`_m?&Mht*UfR?4Mwie4`}9`?_5Cy7k{hO^LBBKHoR3TPLcA{V;%(+D|IR;c@BJDT zFGYN{{DvG)5L#Z^#Pke4$l+M&-bBZ&sed~c$usbv`MF@T6VsG{!Ugc-U4b=q zRf=qHy!9<;2ZvrUs4;WMJM!Qm1WH*nOR)<>TcCT}PtT!~hLr&tU@5cQk7F1qP`L$*#Fv$R#0)I$t)>2ij?_&?yl6+^O{nVZ?ly$C>N&aNydnVAL zW%zv+)pYK)!akAAgU%ei>gPp?pm1jL`SK~H3av>Gqx^yK2n!~5(=C}2z3ujcsnQG) zq+h+IsBG%xOVswFGfK>PY zcKMv|M}GP9nEKKSZi18MC?pTC#_HBHs@fR8O$+9$7DJ7Qp@(oUoG@Pm1a~sgBLW!= zPrhgh@r`{|7f17&dABJ1D#J#CFZxXHpw%H9P~hA4M{~Xq}GFPM+4rQ{b)jQwI=u*hX}y&56P9(9KXRIsReNC@xxVxIZ{5UuCpU{!hIS)a{q zdWruM_-!|2CRgHhDbqUT%|UF`X+GkhbrzBhVOTr#P}&{hSzHGsx!QH(kuPnVn$~BoIA6 zU%Ol0eqGNC=XqdriMrKM_kb3Q z4)cp)>rg;yg-Gvc+4{`Rs0x;S%~xY`;P7N_Da`d>M2;p^=NAE-E-lS zKt=gJ>X!Pdle>S4f+O^WfrQ{L=I(Q{v1XoP-99+g!2n4svOcn1i&p@AUmE7pmQwq8 z^w-t*Uji3}PDDX4l-0u9%%p^AuyHx2lME|1&%k{KSOP zq6a?}Er~M!O=qY+B29p0zW2Da^ASfPah3R+tU6e-$Pv_n=*i0CrV7&8_0=n*&yJX0 zGnCzx5q4pg+x=M;<6w99XuNmEI7@s@{;Rt(UMwHSUGH|0>9iY6%pNg(CA*XamG+P| z!>{uzjeSeL#Hw-h)7xGv`1$UvwKv_EwOBerIpG3>bM%&9e;panI|wCt8? zp`8@DoDW5;yI!Xy1XPrk#IL_hDj1Mmoz-c1{&7;3TTusZYrWMRjaB*lphx}*rjVM*)Xq=JuwM-L_X~ zfZ6}rIs2WHh+`@pEqYLKkMZZbb@4>@0iQDc6o1ixK8&F*o;CSOYc6`arf4y<8KiuR_E>Ehl_xF;~(d zhJ>s8m~MTdNUA8H0d4mTVqkPaZqu7H#=a-nbx4bmjEs;sP3k7V9fQ4pR`3GdTf$l% z`J5oby4O$fz5`m3%SAMo`VyK0#)L>&xHh{z{7WRlm$_7d)Esw=h>Bh2>*;NhwenzPOfv}vyl?-98+mp`mxf@<~ z&L;*%R}CC|Z{X4-&j_VgJlc!vS8d-I`oi&NL+0v8x0oXOYff35thGXaq|ht6d3r!Y z?T4^kc*B!_)6z$LxO`zyh}m^R0xk-d<0VKg4;E|ifq8u#&Mf8M<=CBvO3&nv)5*T2 ze*D_^^_xydkcGwCx8G`3g6@2iIEGu03gP4ZNh_8+a{+N^T@fB()LY-Y#@ye}-8F2z z%MILCz86}Mi0@+!-?Jv4_0ZfF3E-ZKoD_L4Af~~2#8cG$G47pO-$%D0-H@^Hg_^pv zpGTA>y4-t?#Tb9f%$fbzq1Wyb25Sslbgl$f_a>SW6O6UV@wOK~JUaO}ul3UOQ**nO zQ?KTJ1`q=;)d&kWGQAT6^?H@dNv2e#|Qy)S8Klet1XqC8jPFk+bP)=9754X zoe82s)#6NpJXbfLPRxC(2<`Cv{D{KzP_yB!>H6THEp2_HKe18rEcBP>@9QycuOD8o zK89guwctmc`R2ZTN7^O2WN{v=sz>Ia1H}s&!bf|MC=s&w)eEo+`+u|my|F%t@|R;A zkxt7nBUM^-hisv&vG3O2m~Iw2hB?Eh zzT`X|R(d7HJ2}isJj*|aTzTrn@=d*jdqKR4AHgG@sBnP9lZ z%cC-|D+fJ+m<1S45(bR za1%=MG$mo4O$aFnyfBT#HAq|HjpKFjajZcBr@*6S4LCE;@~3(h_?xM?{V;!jxkuG| z8^r+^6z6bR#%Ulr}8Lc98(@8xTl1E|4!&uOOFeJXXG|f zjmlpsgw!QA8i|CZ{W!BzV%13s1^+sV`*GhxtlEGKan_t>@|HqRaQWBy1_hOLsrY17 z*5KhhnfJq`Ho31$j&g`G_F^L`FG`v!)>PBkL3U14HiNYFs~5=}Z4yr0LNUUCCh z&#zAPpSK3Z1?uj5dRUJgH-|mjCwLYLj%UPuO5g&ud+2+JWs?ki#wgw;=bP%mjDB2jyVtgy>dD@BNXo2a*yE{bgja2M*Kcz1Opw16m3F%mftB0 zrkRs)A=Mg4?y4Kfa&o)!I&(0zI{!&-K_^LWx9L$+N4VT>7)SZ)-C^+>uEe;^7CxM_ z?0LiDJ<(A&m&#*I!D??4hP&*8NLL7^oR_%4)Qg3+(R$R6Gp~MbNk>MD$6TV?T{|t5 z|NWu;lim*JQlMq1dWh+KB2P{pJo#q z_EglFKTI{`BVAbeOSzQFOXw4@*);^NPqn~zD7Tc&W}L(C5p#Kb6UN<3&Sf(2v4d@Y zxR}K0gd*EyZiH;WU+dv7ru?mAwaUM*Mp90BXD;yq&9nCcB|t54APsjJM|;QDaneO& zH}F__65Q2j{BZ+WsobGiZTu36)=VudE>)qe26x4=o}vEMzvqIvMPAPAutIT)X`CuP z3YzZ*v2^&dKwE#cMRO_5@OH1by2|eXp0^hM?4Fm@lTRtUEZZg7>Nsgh{m@;YW+MAJ zJv52|S^2!@5N;^@KoB{WN$FFw5sn2bllE%LI`@-P2%5Aw=OOWdwvxXkC6ZyJfblQq zGK2WZ!D;kC;vl;~;$qkn!>z7%pS6dS>>hKgMgDe=mnnZa>2-Uo&G`H_ZnviYq;JVO zS2X|UxLLmb)6T5>dZJ=t-;z+=!Ds00EH1X**Lf?Z`#b9Cl=)4aoBF572X~T!Cu+X$ zEGK|$Li4|?@mhj2q=qcg;EMC=+qpnZP0fRhcpC%5G`HwE8y4_ZUUtaAT1{7Hn4)_9 zk;bL<)l`IA1ny+Y!{-nC+G|f#M+gMVR<*JDEsJS{j7SLC|F~>EK^Yhqk92MRM_-n3 z`C&`{RMtFdxP?&gUT!m)*`57|e*D)|$L>Hb+R_JV}ukuq%2LrH|)YY{pL?7&E0g|XI=z;C{#Fp!E8Rd_sxlNK6w_X zT|xt=jPW5qv{T|oP8B)KwHpQwt!utM752FKff`<}nYvD#*4*WeqF7#Yxk$p4yVuEY z) zfsRCbR=9NN&X+~wUvGMM{?#nCxAMQdlhh&SE%f%$)QK7B5!QQx;vXZa{&Z1=+5NT64ko zXEn_eTxpmHa|V|GjlWo3FUQ>;Xp%^2xzD}ludJxT9lYk}`@+00r#SYn$ao<-&5A)W;%pAyS*+YwKpGpY4X}R=GUF z;G*jVMJE<0M|wW7|9Q>RyO1B>j}Xm>&*PDV714&t<3SCoX}8Gorad+&pzpVKa1<^Y zQFvs6^DSE>&KSC08uld;mnyHwf+r^WuM!n^ua>EAV;|Ri8qs0L(uS@MUwMr5FG1j} zud#-O@ue(PxzdhyqXlu+zJ?7kSKpEIcFy%Q8>pz<*|uLPr^uR%}CB!TR5%|p$c@@0y-l8+rq zRqYzbP$>eSu;U34cxhw<6H1bh4V+7WFRdFnH6ghlv>)L)l*^Jp>E{kx6>|CcdUtY) zL)|k2?mC~4w-#*+KxesiGHwH?74Zs*Z0itlPKqNW?9FyIS%r|9o?&in-My$t>xZLtAJ=x{{_Bq8fRZg#pQlb~eT?I3GcCS{bo4}|3 zLb8xSnR*f7qi926g+gR-LSpimR|ep;nHOuY;Qny;UEX}q?-jRh6bqD~MYHdlc|6_a z1Uc7!xcsB~2C!i!S};)xoSO810^1tl}v6BJ+6Sh`@4&B znS;Yk#2c?GYS3gS2mA-Nu|BqNPtHm|(4t```lu@;6g|^mShzXg+qm2Q_uscGb>sqy z-|wySfD&*`6Sz2hrOiZ6IP1C0DUj{M8NThXn-0f;2q`;ru*Y`OQCv#n#KNK%yZ}_m zMHYBoWsKe{m74xUr`Eq0mQV(fxqWxx?pNKQ2c4tobBd1WYt-%nD;Q_%@4O1z6O3q= zks5`&lK)}_kW2h4E)P!zSiSBoxxxpXv)p#&j)D_RenQ)AGJXZPSXNIDkzPa&G0rB+Cw+8~;1@_K@h# z%Zq04rc~jCJjF=1QG?>*;>O<5SQ6v#+{!Kt0P}qTc6p3VsTx|aO8C58Z9gs4TD1dB zL8+3#~=4w;&hT#~$30faD^keB5<@mm1;fz&=75ZRU@EYI)K z@}z4rI<(`HlC&h7sK>%(%b2>|FZpy@egyD-TV481vMbrO^7>nzXk3-eUateJPrBec zPH7Bt2n3}+b5c<3&uuqtSP&kb$G$t)$5_y=J9~u;&ZEr4!X8wW1aDX&HI}tLpV7E3 z)S8Z?ppk197b@7k5w5<(il&Y{L{qmNV z&qJH_^K0^5c=|plqAi;4@QMOIM>Ov=gY3~u7+dSmDbp%Ze0})&0VY3 z#M{J2%mpbAF{B77AquK` z>pEKjt`+BUaE@X36uWrqL|99*+{0nfLqUZz_(|d0_*)_5C;4x5goH!?Gyrc=?`+0= z5CUtAU?KWDp1YbeU>F@0XqEQM>~H>*A3a{x7JW3^P4t_xE%qcR0N#-0UrdXlh&9q+ z>wgV*e#hpmR`z_SU1ugFoa>S#bCjf0=xR!Kq@{%Zyzg?i?lVWMzA+{V0y`^?f6y=C zNTX+4R2N@r2CK-LbmJ0Ua-lJwB^ridg^=@?PAVN(82^Wp`t;rPVm;~!_eWsW?aDUu z3PomV%}hcY=Gg;T{;aNO$`i(YD>U&WVfJ=qpuhUQv;1?%>@LB?z1culrC*ER=cqHpuY-SS!LM&|NGMUM3ml*(3pR~4pps(Gb8-cv>R%aot71pn%-)8k4& z)pc3^izE$m5=8q^Dv0bcMx!w$#-tkt7w1ttRd1|?(a1PDZKdYE_X+J4+wQyjo>c^< z0qnr%~@ZgDya&5o-Z4;0cJXnN}<*&=uCdQGx)_?@o z%IIhvG7m9z`d^|j?~(rNF;^uT*S{I^&alLs=LAW}-(T1tekj(kM0vdeI0%@o zpbAytnkzn@8ZCB!Tk+^|hyB=!fuRkBgux4!Upl;EleSaAkgA`&i!CZ<)`S`jW@db4 zq;{HMPYIhZF45Zm% zP|Qwz8B7m2|DO1KiZPR-sW*scfD&eF=_hSE%Urp^;pQpQZ?^yO?C+x}!COpBYxlrU z$?S>nb~}skfE^YSFzi*!VvR|1cqQofuiMez?TsDIKU+bDIyr)%v*WAvoVQUDAT}HSVzNe`?T3 z^4{d9y+`ypuq_udL+6hS^uS;{eD=>lOQ$~^1VNBH#Y)wdBrQK)a=8dT;(22y`DbLi zsaS{VCkEP(9bYk5wj}nzpILS*u?&Gc=o^PNl(YX@eCYf2^60S>S5DLoWlpw{TVvVq zTas1id7*QyPmI&rCCyj_eDd^hO!@TA$1@Udm_%96@x43$ff3Ovtns(B?%;3B-ziSH z!LtLl!DijBs|U@Pk`ml{w@xXQnx(+C=%7JC289mX0b(V!)jfm2p)SeQ6YK#n$jY3w zgz(_%wCVlMqiseoibf6r#imc3;uRrf10^+M1L|87OxN_x{RgY7%bITRimqc}42?(> zW*Yb|+OPr2GLa(O7@(c)4~RK_v7=oFq9U&3{UH()*x}&0w9>CRs)Ih*jh~hd$FV#X zeQiyCZbJQlM7%UXZvhE@4Kc2uRNyLHcTex^wxi_n6d3p|u0&FHxojUf?4B8gWvp>N z?;97DjT^Hr{?hT*8zJD_Rn*oY=6lWI1x0Aib%-1BC~rB60(ZF?$e1qS>`7n;{w}P4 z&(b03{d+cj_vz;!DxrloqQMiP`4JT7`VdCoA{V1>*+puo!eNbmZzoy{s&b8$?Bff2 zfgt8kQsgoBWHclb<v}@bsH{i zXBkbQsTjK~juo_mysYdlQfh~#?Ju$pOWSq*K{lV&ei+PkSlX_0rB;`knRMP_J@~2l zwbS<+cW#-EfLlj{7MVabELicAGM^pty5u3q3|YUge@nvCXqF@I<$zox(U9DOLYrUy zbPzmC;gs((yGPooFzw*plL?h)gcF9XojJ2PxEmqe_aw8HMFxJR$T=&o*3GZLmHKf3 zL*7;-ep(w(EX2ca3{y;G%Sr}LvKL|{PbX_x%JQ=%b-GH8#=#wN*>%TQ;hItTBrhud z(x}ROS83`nCWTZdm$jgG1U+5?9cy-oS_yDF(7*L3JNz?epALRL&%Si6$n>%nq#nG! zrV|8u{r%_Nl;)XS_N^EBpKa+-pCs-j%Iq$CnUmrOT+V$a=2i{l2SiTf(saRM=Wh<} zaclFUY8d{tn^Ng)+U}n6PW+yr$C=S;f<*E?TKfE45ce~}YzD}TlZ1ckUJ;CI$Nrq2 zX$+2mC$vLs=Yh8z-H7c<--1h`TtRH`&k5bd^GBc#9wrN+K!pco5#R-bmgeBV_{D@x zV$i%S;M>&=c-WqfANgy%&u~G`Z@s~;Gq*zMiRhltM12u%|cMVABk17A2evkW@OR>ol@ZtA*uF0j?(+Z3;!H zR#qj#H`i_6Y&=MFTxcZ`zPb&P`m*Ml|JLtO&jD#92AX4VP+l=Ht*7KK`QA=tZ2w?j zR`!vZ!i!-yg`*SxGmYPgdj&SMCkUiedSzo>Of!svip?%;=?GJzk+Co>4)IjI`}YT< z>KoT3O2$ys6(SELJ-_nB-Gchs5A-(2-?Hz*!deyz;A|uOxQUg#f3UGb-Akg<9TW$1 z1QriD0zNBmw_{Zy>>)>hEFl0D6x^d3r6rK0?8HX9OAHGTW&IDdpx-O~ju=H(FUirX zw9Wos1+tiXPjX?v!m;LuU&q8{c12$|i0u$+YUVw5Zif%kXQlFs?btt=cya{D9U3(+ z?z^bIx4}Pgnu~GsIG_!X9S7SCgJ}<1PX& zeelen-95ja_CrE$`ZLP0k%hTlS}tj}&Zv3569Xfchz=)nGi_}%Ggdp?e@=-{qqqh& z;Hs?kjo~MWWzh+v{|%_kb~`sp0{QvEF-F}+254(p?vgq6gHXYFLc*{_a!=`kvcW|& zQWRJv!0y6!TWY8)K0_;$@EP?C;ua186zsBb&j=D1qZ7g;gRi&^y*ZPWP02#T_3*7& zev2vQ!#`QJ2xT=kb5Iwe3l*X$3*+*lJB0mL!*i^ochTMOjuFHHno4R=0CV%KL$F2! zNHdB~n1(S?F#qY5Bc?sIvcS>)m51nPJ@iRwz6wYhlgAW&6X*ooqQ|CZ!d0O``Fy^x zPYw482?5#3dvtfXS&jg;ug?bUgyV`Qj^EB@Cy=AVTDIU9)H(uLP6gN@kO;{-hsbQ) zQ$j4fKRJYSUyjHvFY#(*(STUrhs@%M*9WU_U3lF|Ix6lM?bQ7WTpDwJD48|&sl%eL zQ^fMHD!sVnE_d2tRl06CP6yv1x>K`qzr_Uo@T4Oe9)}L%ytsrKuE@O+Vn2OM8xsP1 z)J_78kWel)y0}7k&(Ul&Gtd!ppP*0SXJ@YG9g)Ayfvn1S`;=gE&w~1v3OE%s7-m=p zXhFj*W@y-%&_Ur8GaYox#=7LR;Y!aD-LG4mkmDg`Ox^@Woxt`ns|J0O&re)h7D@I5 zydrYTRtN@}-X(0e3=7X>VNx9*JIZ8Bv`cpNu1-0JnXI9LU}^yM1i~en1L@N*6{Z5d-;@*Ar7|_q~)ql;q6R#bm&2Lt#OgV}Mmd%tZ2%3wXGx zoBerxR&3{w=lDLisNLrF?e^z}+$Z7Dq(tpFk#l{m*dLum)|)67Fd9aYg)2b?9H9To z`9cHyXPEdu3jIsS^)ecgK0mf#f33uH>9#SkqC)oElzS&Uav>N zS+U?5&+)Je*Q?;`6-6-D>9y5~f4W?QLWz5mu=|hn2cLo!0%TXI;BTtX2?VRE>c?Ax z1*_uF-6ei*1&wdA3NX2#@UwZqP-o|N6EqfFdaGVbNF-`qmF1@`6NA7xO{pFAc6ZU& zRq6|_;ya7*SSO*SN0iz$MYJ~kDC|x*8G#8lVGNk6CB+v8(#}*ZWb(f>p`Me&{5E-# zn?hi5{qkEJskJcX%s`G;zmS!~pEl6J7z`OZ7KnADot0buX|`dvnW_(|Mn`Xi%zhxt zae{OGu>EBZ-rJG{o0pdq0@@ztkaVcU-$i$<8V-)o_E{!!xfpANK?KisjOSvzrh_d5r9KScVL_TN^xGl}sp#*XLkkiuYFXVDFR zHt1yzC4X4{w#@E5MhFUPp#9N11f6+4a;m<2y>*k&nlCAN6bjwv)YMCmms*EN6k%O-3n8?=@eRJ z`35&V%KJDEeUTc$qyjKthxs5X zBNCp%w+bEzkE_!ioQpr5O{hO$ZpB;5eVHB==mU19hCu!`Lnc4absh3Fz-HMz+Rki*|PIh*7RxkDGa95S(*`2gkxdHtQ^^T=T zo(@wtv%+eBSe<}*v!w#3u9-J8(E~_(B{w16N-~e z{xzY5u(FdD)7$#0+jEn(+R@sDy?t+HF4H~Eqw0|sJ9D-;kWVXJVqG;g4S03X!kFJ@ zl1=Y&zYjJcruL+lt#V$Q96omrwJQuW+a?Vki`lKA?2}CciT2-RF_!i~I0y1@<$GQl z#XhjnI)%`cJRDjmoHB3_c|YiM;-19EUqh)EPJyoEu*!QVw7(U3+=geOeHfkq?hVOG`jNDf%6F88aUHoJ-FH^jw&5$z;V%s=XS-Rjhip^gDN{Pb1`lhR& z%3_!*pj`M|4ZVaq4po7)>t|M`#bFM*qhv5}-bS z*ZTQs@AsS9gO$&Z6+CU5_8~-#V$Vd}om2D~IpMGD@-d(I-j z1{??-CKQ9ZWDNU7>X0f#FZ-8KJ9T06pfUihWm@EWqWJF#+q|!qj;B6)nqG-fakYOJ zVEP#WrcfgZGZhJ7FU3>}Nt> zzL+p!vIvPh{<*XBeXp7;(NudY!wJm`Rd~OiRl%^*(RuSje94_Twp>y>Cg$jb{%#l> zV~y7x!Gf9wErCQvUV`5tr!pU}zW1RW9h~rj|7p5DdJcO=Hp%EIQI3f7K@d$~Tw*Q& zw|tld7T*T7#o$z7ve;K+;@B~%oh$w-&id5n2ltxakTLj9?+_k3x)B)=sTpe9k0*{w z%JbX<-+wlLLD}E0gqIkRN!t^_efDs~%GaA<0Z|fzZ+!)-$fNkeK5UUQC@Iy1S5=r( z2B4=upt3t)?gbtQ@cpaI3q33^`_#om zci#X|khhU>3xb`gDQZ*|N`|K`oOq}t%Ppa{$`@Ad^3l-+CH)fdK)nBu9D0fidI#e&m-rxaSdqOS z3ODY?b9=6w6^QFGBW(b467osL#ovtGfaqFxWrc6m9}^}dWHN9{rOVneByS^NhbM~z z>DPh-7R&2xXutZXK$Z!$z>xE7vtntyZ!1jVqWJJbuZj;=oc0|`%j}49RBF^)^&D=f zZ}@RY)EPRu>Y+|F0j3rj%Gd>gE^!7*P!oAM?R@6vKd;W$Gt?1azV@tkQvf z1Mu}VVy3F6xi(*CBT#D2(UjrWz6NL?2f#w9Q8lPYD)@VB|B1TK3G1kD$=PtYZ{}i% z1qj_f&-tHjWc+tXfNM5FC3Y4WkrZI7x-Gb?w}cu=78WUZi6?d(67jz3xFC01YYAUM zhBUolMC0YuFADn{vY4cc4gr?xqTocNGF2#qAGYE%AQDD$_5cYDCNNF$xQ7LdJ!LDB zvcnIZmj4`>@S4(p@`?AQ-ON|u=CZ|YXtgd1tT(%Nt8(W#cXwM3}&HfB!o0upyniPrdxt zeedQIr>?1)sd(POZ8ms{j-qy0t!Y|=IXEGNSe%xU77V2Nr-;Oiw812}9^tSLr8!2P z<|Jj>oA!9LK!L@RF{xsrflC%1CTGKYlE;6ryhqm5@E!pEP2fLRo?VE&J)Bk1(W#?A zZ3h$JVSQ4-17r&Pr1RrgO5I@& z-e6vWMfEI;h=k{!dBQ15x|0=ZChv~d#XxAZnx;#Hr@D>X7A5-`13`#@Q z!0#F-Qd<5V!EMN`wNu{99NmF^1^@~1T~p!?n2{VDALMGI#q+OU&@@kK^b}^~i{W$l zoC(brws-%K5NSU(rwEZg|3!~!<$vZ3?**-$9R8`;EZSW=_*!1@$q+h=+Sz#JZ9rW^ z8X6kXE;G~pF(d9pV)0l6a%pNtu9lYGm-avTuQ(XEN_-2a1?Kk##UE^)^p>yOEQTSf zIkk-7KW>RCE4+WIGB@tqh#}u?6=tb#DGKY3fKJk2Jj64QyOsIQj zl;;@9Fa{r`+_9JNK19w#0FbuE%X|57mZqH*Q;8;c{0}({N6i0l+vb|nVBTqy zABco0oH{#Dz24sYp=Z%}VY}g((Y9aBp39#*m|zSle9F;KPpdoEyM&?um+d9`mQXOW zS(H7jzNCB;{;A{F8WG+mxd)=g@+3$3V;R+V^CeTg|B4J`;G&q3h74xZM0M!?KULv) zLY)cv1&&T#w~3VVD>hC{gA!w z3OC0&WN(|59J05k{0{kls<*Onm=OC!6RIZ6JLQ5o(_<(&pqO+eP&(%LksW2prZU0b zVsLnn&<<}RF%=P^Y9`LgpN#M(4cOK%%U;hBcL@oWQ^gGnvw)Xm77D`KWRJo`K@pj> z%OP7vT96;TRPZrpW!SVX!v7fPbcSOnZC|Iv?I1tPDJepNVrjVtcNf43_v7e=ed+rc zG7buvEih~h#)*RBy*))22%YoJFB&O~75$0-<+D$Y!1*lmfBEd`|MJ=FaB|M!&dmj# zJ=?c0Dhi6huaK9Ff)n2MPJGpZA4#g=s!Uq++vFYt(sf9{HssYMuNwu#4@Sxh(PTU%Tn)~t(7fr0 z{--r$+P6;KMSj?bnM3SV%|6W8B@>SS)!O$0c^pK#-1x=ZtdrItx!Z4GY`Ebv5 zir2@Zy%9)hV+`7U6MnaPZHs5cT1mKsCh_rE#m>owjnm z2!m#>66(TL8Sut!R3>=Opm2bciX0rWz%wTs8xPgRye(-k!GG-J01l_R&HxoWvHo2_ z96SiM^V-cj2=vXieK_Wg3hvuEb~p_eE&(up^xqB&?D+fhz0w7xf(#h(SWrjSxoH!c z-i1}nxSBbNrA#_&R@2-^u!5F^4{Y?$0}Nm~7QT!gAM1)7K8b;}Ps7@l%M5({T>6@o{nUZ|j8% zrryOID9n{~cKu%Vat1C)uMVhX#w_wby=yNatpa(dhjgc#;2$B7%uK*)rM=9r_n&<0 zEhHO`NC}0@b3+vg7!lz=ixu(IreW%4YM)^+viTb49u(SM@9Of{Sx1rmUwiZ3Kkh_~aYRylL9%@ur+_3?)Cuk2g%3qSl zoescjzpT34YDR*?^h|RfuB?6O_*-!Vp5u^nf8({ohzWrWwtIb}8xsu+JC3~2e{Nv6 zBw0&a8>t#15eHwo{98F0rvT2coHxvX4_V1$&Pi`^&mqBsJ4eIV8N~CO3((0+2SRQ{ zxj^pn&Y}-S@?QLKWT2=MopA93P4)cCC6s2VNIU_)i_y6jLvelvlVN676nZZooUPx` zXrA87ZCv;AI6L;a?rFPrnMdi+NPL;Mi)^*<8<+I=e||l3Oh{IkJmJQLe!zR;SiZsF zWKQC2cVakqLST26qB!BoF>QT@94V*U5pG<-2nG#XS-+_P1{+>1n}_Ft)I zQO1m~DP!<0)8etP452|)QbWINLAR@uDac2uYymNVJ)+t9TNh9F_lXa$XUNq#$~=nP z^kx9d1tKls{vc-#)fqd)FUfm@$;onway+3fWYTScqm|Js4mP?QkmOwzE+1LHL}MMG z!$vaMBC@1O{yQU-f%|?G>J_I<2d*tFda96_IBZm3m|Z`behzU$E0^A#7dWdoVdHe| ztPY7!hR@cw^__d z12kHU5HkBvov|h{DqZmMKKB!7u!JKkLGoi?=ikzwhP<*mfUK^=6w-ryC!DSQPQKwOFuTFxb5FND@Na11n>Hi|LW_DY};stj+@L+gQEV zRF34&-=V=jE4$w=+Lh>n95D#kLtD0F8tzN&rKO@A_LcuhaN>eMaa!>7j(eMRbU2 z`lXMCM-_nF_giQAfC~QmHu#*E>gH7;k8ngI2~RC@*#7n=V=+M!PPF?Zgv58@6FynP9 z&s>xP{yvu4A$o*_bgMxMa6<|S&*S8Ho`52{b8mWFc#B&}E~iO|eE)FV9JgmQW$Z|_ zhaoDuwb_Fma-TDE@JR{$@-wa@SS)xzYs)4r>0FM_W-%jqdQKj;#Hadl%+SmXFE2c! z|3`1}zI*_C9PZ)tzj_N{zke?rH@l|)S8t*1-f7UlVo2M(73s9KBzA2^{0Ell$D!WB z{J&?@B)7A0=}+<$oN2*7w{=d+WrvM;kOR7N`F5Jp0a}YYY&4{)7Q-EOBi%n(Oe3Kx zkQwer!OOgety(zqKaa!)Rj4`4LP{OU@I)m}UMLX|l7do_#|-hraahV@=9aj$lz*!^ z#y1=(!Xsf$D!0BS_1H%!9QWi0tPu;~Wrlv1_Hu_YApF1@shwY65WJS9@;oYnz<^_( zQorMb{7KrbWujJrn2@5r`xj6rQnOG@qh;(*DIaRroy&Dv37OZz_I zN))mc2$*MS#=v4lfu65i$6ZpKq| zl=PO}wDxE`{mURf?57Y!(+e$jDCP)M zN$@yC%Z;t*AaNewE2?nFztr97yXO%5ljSfI(RIed`yVNDwz(VS$Wnx)s zB6P0_v73SKufdwE7D~k=7hgO6`z@#3$p(n0+qYH~VZjZb+sZ29Y1^EKsbUx` zP1QH2Udgh&?iO@U;eWm{EVH-I!F6@wB_VaGotN?j02hUMJn?(L*L?}s~cCBQD*LR^>hc|cFF@ke+r zGqcC%0c-Y-e{uB}N!#+I@&t_^ZEqJ@BsbdPZ*{X95amqxt9d0qD@1TL-4Oe%aBJq* zXo}u#!*53)Q*J-*x?_{Am|4ATk!cp$cp>WxH#PFW0Vhp%B)r-x@-CQfybqY}KabJR zfBu*qi1qGZkM;8-*PUhj@U}@n6Zoyirfn{6k@t|P=t6enhUmY0umxzOeg>hXR zFHge)!%(v__2>QebH>!y1GGP{2!B|#jS<{W1v$W5*KxmiBT3w63e~Pov_9juqRTMC zeLn&fUN@<{qZjIMzyQC;LAEPb6x{VGuoPg2v%Q3m zexHiSfxTm-+Y6+0tI{T&J|%{L$H7E;`^pQNJ)1UE&Huz~*dWan%3 zWQN$424+05(oVTkBBpH6`2bf?>E#k zU-YQZ1BUxWI^4=vKA2zOe$SDP$uS?aEzLPc6lO~m9j)?IWsuM;C>`azyLvY&?D0Eq zLzETW=PSLv^QfZ(3=g?M#Ohk*W>L)2VyWQ#g=5bpm$j(zxB=F*c~8!Nkv zP9W?@smQ|Y3*4m@=XQ?ml>T$KYWqE372&ewd5*z-YU^m-J5NDzJE;$c-zw z)etj4Q^kbw;fbjMEI(}L{_YcppZ|%l&2lJ-c5RM3_#*PA=uOmN z1IAug&o?Df-%HnXY2!mR8a?6Cf>QxWjkPvfq9Sn$#n1Turr2NKd|%ipw&QNzzhV0? z>HCS`zn$mcwO+#l2sOkgbv9j0rc7Of8;Tv0gl=|;d|(aZ#(rv?MIs)=N~#Tlmx8HkaAkpMTuI;QDYqaj&vIDfzpM#!H3f<+P*V0!T>JKp>>S*!!%Yn3YO|S zz%~pmwJY%4IVZG-qSvl63a`R;Pm8O98qzHlX%XN~wlUmTP((QI4)-O*Dy*b@WfRJr z9&~VRSjQFlI@r+BF<-{k>i(`;$P&uiKi~Ie`j}>-18Dz@6?iz?@Vmb{yFC0yEaVYv z>k3D8um6dSIvA{LDPSHu%woQmBz%Tn%xj3@k*BwE z`3qbl%^eLF3qr|d;=A>?)`^11^Pl@xfh?Bhj(1xRu-swCUYpx=l{*Qp%%$=k9ben# z`KLs+Y&9sFihx*36-tz$~_;(uJPnrW;g`fP25w`qcUE=WVUte zn9a<5{P;sI6R@g}iNfJRA}!G0O9JwTTK*rZ-a8)7rVSfT5GC3z!V1xQ1W~ejkCrGA zK}1VpiLi*?i4rB+vLsQXg&^AMy@p+)*GunpwP39l@8y1;=X>Ape?PyyX6BqZ=a{23 z9y1)O!~cp*sM@S*hr(rVymJ4NZ?H=~*WpSNgK1i|^=-&wpGp$~v$odUsv`N4XHSk6 zcPTysGM^e(Zt(nhi}hxQTDrIEyB{dxAo}{s?jt&PqPy3b*=P7FG)Y;(wgTlNqa z6EToY=&{@6{ZMpKDBFpib;}+il_XOl`I-OH@zRvfWH}F0CklOv%TWa1jU}!D>Q!k1 zMlu`S3Bqj?EXF(s3_Y^7+mk{@>#}oUhO1-n$Q$fdm|i_&{6I4$^1C8DM(Uf zD4(j?qj?L+VuH?(~>jdv=P?uwk}st<;;ikNXR47{75 zdxO$$2^VM={$U1K@0R6BOhl1g%&+gTT&SFXSdCMnUgkQF{{1{ z%k)pmCahPcVjh1_I3F!2FlO!i+uw)T=nMEFABSS{O}NO7IXqdtr!)E9dpHEaT!vhs zX4co4w7?$x(#r{YXjRk};2j7bOr*9rPyXdxOr4fKzEzg?`1{tMiH+y+{T4kWrsE&$ zVvnbfdtL8U9t959#9B-}`TmT~f9Ig)FYoCdbHiokFjM{rn2+~ut}6O|%-P$a)v$7% zxZK`MGSpF~VvyYJ_14P}l>GMV$QOuk0s`MSs}I*-4-;!D6=;z~6!D2hbqug0q6o@% zLaFTVx_hq|QYn}xFWFmm8jh_)RX)Y1C5xseP%9}@y;WNdVf4ozrX8;)(Cpnea0sEQ zF!I+y|HVMemS-CkJG+c27Q)C`MRcGWsv}Xc=BaS*(7CUWc1JGXjV01w^mpQFZJrLu zwPr_#e~bl{WVfB;>KXXXBvz!!f9quE9ct? zGye32{gE2UMs#u2lu1qAI}uoydzm3->-SGW<5ypK{Fo>tpSGm@Bhj1i<5{R#*Ia*t zW~bL^r55KoVNY(pZ|_~Xe#FRw%q~%mLe-1IDxvv^w&NE?yLRNPQF+qyLCinDL1I(6 zup5ml>9y}$6y>2G?N3O;_gz}W$rokpA_i^$Y$hTCLm$`msJA%grr%`N>UD4V9~Z#J znB^nyS=r&0ikck;va=F z3JZ}f6N-RVltdo>FwqtGL6%3g-_6dvXCim<_r@8vzY zN#O*jl()ts6SXyb{#PR>MKKtkL|UZJwYhXTj#5z5LR2Nh$n#@}E1_u1y9bUk7YHX8vI2 z_F>b?YXe@q!CYg#tC;s9`uQfHh^C@)%|Z0?F%1_4>?%QHg+$4n4)v%78;|Dc32{6F{U8(`K2$+a z4yukw0-tzflxxu`)BEq9c*eLm%6FY84%f}<{DVS%e*|is5Z+JaV z$3tG{a7OZDz0pFEko-9t=!`&aGSNu59X=j(9l?Be-9`?G{myHW7@5J#gevI&z~ggM zOx|2Y8)r|A@@nf6n2wWUcNs+R)d$>T)S2g4S|ZZ@sS}SCm_|k?V@>4lu!4$Mi6LY( zIV)=%&si9n{S4MppzhGTq9fnF!M5cyOT;cyHT+ZeyvcOOhC3RxSQbb7-*UUHP@g+G z(eV1j{99Jl@`H-*Z$J;nY;x54JXk(ckQG8s#0eXpQT|xnsvYv~QqgCHMhO=Sj!`q1qn3enyaAs0XT^J9Er&QgXn(Dtmi-vj zFU~D?Q1U39_6nQa~?Ch#76pr1($cORC z^uG^Ld^E*4`_UtGVPU(&AIpHED|B|AExnGaEh;DErUKFrCWEn3I%AXO9D|<`jilkd zCS3^TsT*H=8b^v93_^HQ#FkSD0KpJ5QP1Qtz0XktKb4`^tR!K)ZjF8HG7+a_?ZNPr zhF^YcGW={)q$9`Gl;g>_eVrk@%x$+Q306MY54Q5iIPGeo033^dWasE3suDZSnCb7U8*>ABJcx{U!#e^>p*s`+Zq*GwW+3a2^P$A#%^_$@d5J1 z{<3dM0(ph1w@RUZdW}!Ljj|*B!dPx`PxH4j4|f3RGa|2@fq5mQ0{`SW+a3O#ZNF*b z74e77ec$9?E1O#=plrzscX~)5vM5{USpQdY&*kHats)B~|Lk`OIUJQx0h!^^I8#ql zfI{F*_7~5+HO>hsuSSG_@0Q@B`agQ-clDM%MoJqVrX5J9BO4><`sX%Q4&HbOS;}_m z5}<$zBQfkMai2204pVc`T+09BE-^6M7n8d!vF#hZ{LED7kA>Q1QAkh58CxiJLa3wW zEV;}!Sfz@b&)Dd04IMc(7jsx#<&vb3Qi1-f06A4O)UC%vf`JMMHP?EN4y}sGgXxI> zkrw)({yk!le3Jf4q&VTx=(gw~x=#=<*S8g_UnSeUbK8x16RzYJ{E##uOxax}Ch-nb zkpSh^xcD`92QQ`tDhI6y_+Iw>M4kK{ zRabV8uiV(d+W&Lc@!h1uM(Ohd--OC%#No$sa;6oyO%vQX-^Yf+(^Ji#Do@r)08f78 z>(s^+3~b(Lq){vMsG=YDgViq}pq(19FB&uZc^%eld4Vlyzte$`V?YgWP!r_3Tn2uR zd9zk|KUT7V)JLj~3_VuSj@N`t<&gvMsvwe#nKAn7CLme4XW!nPs!;*5eW#V#>(AJy z5_s>?%ku;iYO4sAPW>VjQlrgZn&ZDzB%k;lzNENXhBp;lGJ2O=@>oFV&n-Z}pI% z&Dr{3cIJIwS*a(>h|2PC6t(S3%H2qh5>hR@8}7PgZKqvIRjvC+^F|HdJWOY$N}Yoj zgnak~-=oc~W0}Y5XGSZOcTI{*SaIz3ILbazw*FIvv0b7>3t|OWHsSPaaZ7*cd$atP z6o9}+t8*Onp2yBn_oS1M2MVFXy5ig=u(1YT(+sFZd&vPE4=YQniznWkJ!h-$r5m4` zar=OkGoQezR2|&>eGAc>t8ywO6)c?ccb&t{cRva|^cGT7#lPHWE&YI{M`fw}jtJcU zb94=6QaAd_Fod4DU0eRfP6cT8nV0kM!v_-r)JdCyejt2t)VC}Sp8R4s(O#hRCj&Pv z@F=4xz(m=7?CjNaH527dLarn$K@lDL^NOv>3%rJip5%PpqnW_#@rapL(SgC=WpyXr zzCD9-5s};$a<|b?E0@Sel5z-UzE}cO07V26By8YQl!az3sg=R8IPi(r zWDurK3GvXp6oVMSFnGBtliB{gVu@*RQz!(4ARs+T0}a z%|Wdf``Xj3!l_#*$`X+fw*7Y{_Tz}_#aBb#RCOcmELY(2?XItPipJ0bd8ZFt6qm|v zZ9?@eM8y2%oHP}4ueke|PsGEF%M6DY$@stg!^J5eiMH?2E)^t=SR%dt=e3o(#=Ot+ z6M4e0@YJVNzTr~v=755_f*Cn?jTH8$yHO!CGeXren#U!q%}26-`r2+vDSnEld~)}M z_=jTk5n+*vd(2-pJ8B*aFEmM_1yDqv2OV z&Wjxbq;-*K&>rIDV)lConb>~&=-uP58M5xF$E44217++Ln9lo*zsy3c<5w`)|T@XFfSDS_m^ z+n<>C5UCf8+T{Mv`ihH0KJn{^QJ}UM$w9IDjgg*p-W`P;gKy3ml`Ca@!0kf+1ICYoHai@U z7Bko)MOCCJai@HMKO7!qIcmi~361o;w6u7r0j&Fh(H5k%3NhJt_8L`*%gmM;09b5G zM;C1t@7Y}*c@_noh*$+3t*GqaxnHH}*nCOL(WPMQXW`>pU$a_+gP+!yM8ry}HdS^s zD-=2#W*wWMiVXt54HyW8udBXHo-}D1l>@GR#;1fqvYpDZUeM&v4TzXoXiQ=UH z-XFrsHvw2=y#04TqctNWznucmaeQ3mTwuvv&y(;0HHWmHie-9}9ux@I_bW!Nr}5n* zC~hx!XL#K>Q(EA$yZUh6w0sV)iC(3E61NGV#S^IBi`E<577xd(AhC8dEVE*t9c0pO zM-N>`>*T5|hNUrL>t$b7-1q$8i|!jHIcSZjZTKl2LXwCjfPd3=1P!PHSDfwvNF=hm zL;V%#^LS^x-9{E28WEd4fTY~!5Z~Qr&;Y`L>Awq1_>WL9;|C1Xs5-u+34w1ylv3d9 zkUMhxw^%dHmL;`(Wj{v5LWD}|NjnR+wmGU7L$EcIss&2u^fiHwF?sfST*1AjnMe4_ z>e=Gr*~)F{ukAy0ovP@*DctP6ah)})cS~rK4+S-n+vKS42P^THwG=Q9J=A!PG4$~y z^{i&>WH>M#%RP2SnaxKp)$B`L75E~s@%t^f zMAv>~J*xJ){atLsm0kE^e~8_-45mQG_1(rKDg+D7pUnv1{pC%!c#sTndu8)EGx9N) zr94r$3cF7z{pUpRQlfJWC6E>udybQOLRTO|jgXYSGYB^~?9{R)k-i3CpQJ-9bCkVN zl`H#-I6;y9IKgwisL*Luw*CF6&v;1+T2>{{$bjWd3N|~TAf4?SJY#JaR^UBg2K$f* zr$>?O8BEwKN5sy4=)D&dm@kjc94_d)H3wr|4AvTMVr z68vWd8wi*k2>V_?`KG<$vsuxD>9Jo?Ea&ng!C2V%m`OmYoCRj~ln7lc5WC;s^p33_ z!hP151ydP0oxK0s7n>4K*{rS0G*I_V{ELi!7z9;~cMUoGo%d7rZ~fyY&cp&LKu|i4 zBiV5n;^Vjh1F|SRC}1g@0ZOsqbS+vz>ZG?E*~GsDs<2Bp{3y4G_zuJ$lbaLq_h2;g z>cbx}J~@voLff^$>>}QA1*RxVk zUtTJ@zpn*1_Kg{)H$QUm41dASrar_`Ei9M9sFExCj^w~SSiR(qtUvbltS%9oOsT96 z_u1rf&Nsa0M_N`tZYEk*V+^j{25r{S4u5PAmJ~bG(to^5-k+`co<*BOWc}@6Mm_0a z2HHKYoCQW5`??u@JdKnK1ow$d-ZNGPH5cY7)tq>+#o6_|e_odY;9v`D*WarIViYtL!V#p|Haig8dEJSmDH|wi z$yR@yj=j95r%ULstFP-mmjLhia;}xM34tBUfdo9sS~&NY{pB0_6v>8xz9{y-iuA|T za5|J6kFXRKA5CnEVVUZIn)||8D&uc8W5>EJaa~ZGnBVjnJO6c@-G?xj$r>Zb)k9q# z7!fFrp&0`5|wU5mZ{Eix<^uk z$H1|WKUk#jx%DaOOXpD@qxx#5R0^vCSR{VMD7H1X5-2TRH%Y+Pc0D3z-q zEVB#pz`-Gz9>)5~?ZCok7^?eU4B`*r8@cS=*W(A{KIlC3jelp-=Fe?HCN;3xq23UX z@!s3>>&90+!wVKrgK{ud#P1F{QL2cvc?^AtkBgU+m4!y_a4gT0>~L^dmDg*$dB9$Y z?NAJfkz=bv)tZIeKYy@G13r19&0xzpMiWBLRQxHlth#AQEf12(AsM`9pR|0>1N~5r z9a8&cUtYKsR=+sX-n}<}@d=KNc#!Nyg7!g{P#6~y-$5ijSkN;&@jXaC9$#}965Ea6 zA5o$sQS#`o`Xcd30E?7cGJD2WJ!4kjZk@WdG}1uh-_Dox8O&OneOlyOBm-+_!DeOH zv-#A4TaMX;uJ!oraD)1TUh^~3{8(N8u(zyZEFE_Xsq!{_5ZV=^{nWGa(}SzqCA{nx zy202_^)r6i_SMcGW_&|h&Obj2Uy(cMu+1<*r^(Tw>^x{tf+R!D*s(0?O_pbn_c_1l z#edt|$rt8o`85@Mxwm*X{-=2Ig2#6C6S}$px8(0bf4%=sexF)=ZQH~?XD74SIGOlN z4(4;@1bg_zS>CdDYVbLlN3SRCM6Ge_`jCZQ{)427wI|l;kbRG0XjCV_pGfIgh2#=M}W;d$>r-O(5lGNk8^x4@szP? zMpp+UEY%0h_+9pH=8?w{43zl1!{G3_-G+JuC`|(;y?rS;G9wg7k@LL$tnJ%DIenl* z+^Q%lUi+rb6QcC3@3DkpI~yBWbi}}znS$X(deJ9^>u8~HrzSUadQ1pBAEw*W(KSAc z`r^FE`O_Pcj$e=VMx@y;>TcUBSdF)T@lXHs_E?`7wZh#k=r;FbnFanvv7iM(CFoMw zu-j0YwgJZ#FMAsEBcG2*4^hS^eX{CK;Md6QoP2mnD5dgggu)JyS=o%d6gSiw-o8Y5K{7_(I zS8s|$?qZa|?stpji?*KhA67n*q5b7NTte&ssK5v5Q18=mX8>#r0E&%sjbHmuDG1Vjg6YlE@Rhl_UvzeL9{^D9>`Av>C1 zquoub$Lv#*w>n)*AoP|0e!m43EHWaN!;qOT#TUgN9qNOUL$l;QNOpPKfAbssGb;1J zCt@&L=A&-aA%yH5$at})Rlw4-Q!A8WSZ>k#U$s!L#n<|S5q?P=to-}?S?PHEFc%Ki zm4qklW1l%!t#e$k@a>k-eb-k=0`M0$EIkoQRNt`S*Rn{ue}CsT*VvegqwCpkp1!}B zCzY$_(5y-g)~@!=IhJ3U>yj%oroAulqiyUy?(RBPs{jeBMgLG}bAPX#FKX2hcZw-_ zjWCm$pw8?6HmS+d>AVQ|tqC-lVt0QUJFV5*i|!h}nY1JXa{H@WFZ}U@D&fPY!=IJccnxg2?B_d@F;B^PzWCMPCTxcBV;lnBwj7}F?6k<{dX5~ z$P7lo&vc7bm%ny12X@)WX(-p{PP64Fv4!wx{2fg}uk1c&E7g%*RBTW**X%YY3sdj) zomSUA?lR5!HY%Thn2C9NJY})J@+RPIZ`G9j=91sIeV2QI2*SQQAzzpYzmF0mh$$gr76zx@nf!+)U;T%~C+@_l);V*}JVmjOggJVmXQVj|I zwkp&n4|p?B)G?@NF`Btwy>)VuX~2}Nt2JKGnXvJ`^5meAvF zc~W=QpC&n%iP8y(G_ZXBHJPb!a(~SbK=NSj8=0VHTFtYUPxTG9+s-^FlNA&>o~wFu zLHRTyp1x71BK5HSsaB=Tu|@f(je!5w$qj(bUzwif=fu9itVdth!Ec7C2Si9l$?ALE zUz7IPG`x@2<&Q7cZ(gn~Mg`ajrLT5_5abrFP{I*reDzE%0=tBF+e{$kHoZ|&W;q}+ z06@xRxuEim(y$!s;2n}Rng`CjINp)i>k|v>p$Y>Mn3bM@FSx|hFWv5EZ>(y%x7qu; z4y(F`SUee44bRe@@H?M>0J;JO@IiHg&T?~=Y9IQJjm^L38>cG^<6p11qvkFB(qShCj(>}(TfI_>^!{cAZ2dhi_nx_&7fhXd@a!%9y8|Wg zU0V^lF?_C>V217uNTvZFlrrS#bsG zS*b{EGXLKbtvpuZS`*9_jQRW~d6ADeD)0=u9iB9$kx^_`9(Q!BbRbC`P{yjrn&{Z! zVL{N;)_lr7tjqIoeYEltDKWLEhm$%uBLa2R&39n^vZ?(K zdw%+T?(bDdz4~EW*MLr;MIDs-yLh>{MOT__j2fC)AE&{JSG{{PCfI~a?PDzH?;6H` zMO$G8X)88W#|-Q9V~or~1i9EBSF{z}H3Rt=ikbPZ@YpNb3Y8Zs0CshGAbjb2-e`sJ zJFJZmK5N!Jzg-Ynoc|Ke@GhV{{Oaw0Ft5nep{zCNXzG`#!;{qsj<|JcMcnS)1*0jv zyxVk6RWk^MrgmGO&pKNFvkUYRwuMNyc0)0EhKH&0RpRdoR-EDK0|> z1C;_IUf+ady2edvx<9cSH9n)#9yeAqm&P^*a5cZmqUEc%`nt{LekO)9dbY{&g9G9B zosFrA)ur})N37p*yYOwj*V-@DlR}V^WJZn#sch3rztl@3eZwj@k>VH~XC3IIYUeA2 zoTNr%Z0U)OGw2)VR^-k0{uxs=FRzKPd~&aP2go?vB5h_WJ<*7cWms7AMrnu4QI{T1 zoRTVx(6}CKk9(@gV-=*hJsxo-$W7hr4rMYUAk>lboZNgz%Vl`Y9ouM?sx zH}xvKJ&##}dudWEi0esv8!(9I>TADal3pGKrps$V7QSMIbeER2DS=s68XW9$`ei+- z4gJ^C_m|(kkc)|t3ts|Z1x**mNtTg1V+0iC%xPgI%#AjeX9oHl{cYrmJOhWtd!_|Nj^!t9u@ZbVM=lO{gX_ zqI$)rq-cw?9=i|Y8KQq-kTb(;POUJ5mG!3g<_;pXH~9dYu_c zn-~j0hNtk2u<{$eKe00ZQmSvE<>lITStR6jqTkek=CBwcQP4a&ci$=G)_ILV+LG$~ zc;?(*bu9=Pu`8o{@K-qPgGJ09xvfzZ;De1i?kL-H8W6+mtY|{v@;W1vs;0U)&_7v~ zWAF|ell}NI#{BsvIST>1O!)8ayJ6UhXW^ZXi%b#=P>{4>y6@efm*8l6C|0haRY%UU zIlKN#71?28_kz=4<}RggzqY}j8$b`#+-CRXn5F9O7tHW6>M?aA*S|DZ0)Q8+R83rw zWpph6|Ft`=-XHA6;9&S0GZf7+orST?34%kfuk4PQS6yiu@e{PWpaNZlqPwYA3Xpic z8)rK@lZrK|M1lmOxb?Y@*f;shfp9B`1etMB+8dFjyY!JVyh>FMcQcBt=->t)-#HuP zM+Zj@A+}$+0w*uto}`yA$gZV5W<-eZg&)C@KN(`KJjS z=pwIK5?WEuFD{|~IZ^f8kK+NB7!u6N@uZcT&8CV=*&1A@jA&d=q~t5xwYW@x{n;~C zCNGp7+f=vmX1L0)gN}wisVM_6{&TGux{4fGf0*_(tMD31jb8mYr@OcI~l21HvpLY=ZVbgJgmJTLi`$jDQc) zC`Vf~NSgba4huHxSa?MemKoJ)hoN$+BJD-VB%i2)X49YcmdxG0!peC;Bja2{=3$Tu zH{`Dcm@)H1+-XK-|k&(FtH8A`zGFGy?NrlFL1P?mHV(G8zy0ePQ zQ-O5on-KQjx!|L>_i|$wF@pJh9`OEUxFKu1WKYa1CdyY;LfIQkcdpl?qlNk~#JcW5 zjI4vg&f}oJlrWSRcoBoa=y1hj%*8OBO3-N3(g2<`ZfrzN#Xs)V0O*fL z+N{{F(Pyj%S0`xfNCXbl%zgPRGp@wPPC8Zfcu!rBm0y>D=oO0p`OGC@ zvbrrjT6@)g*`#B0Tu51Fjk%|x3QE?QX7V{!smi6?{`3*OjADgfwxWCGHNi9dH!0nqGfq_itsH8n{v`+ zSQv+Fw>z6mXyYa!VK4>wSvy&sUH;t|olM>oHXmEt$MmeKSv@y_J3N*ds~)g#=IlLg zP7*5b0IQ%`&@*+NgI`R-r$R}%Xivt6&F5p4kkPrIe6gN%bs$z4Mk>4{ag@5liMSFn zMG&F)79WHQjYl#l{j2bP4 z>-=4idHo*2Y*kH~6WNiu?*w~~o>2W^0BUr7cmNh%uwBz|&1droKIRayE_qcnm5}(h z@ON=ydj&k4+-XAcEy%y+P{w!edvJvcN#1Vqqd*mDTa~=Zi!Mop7dIMD<)p&TKsOkf zPf(2U$AYW&@820VDfGYO4Z;rAd+g38rNVSU+GU_wPLF=!EXe}LzUNO`kaf9WS(Cf3 zmlr91bH82KWyTV5I^ncSqXv}OP*|eMB0G^n$|q zb{}D+(}yv4`QlV!;Cc6pwsR_*Sa$R*rx^dWXkuOzaqQS;jel%2%91$@YfzAM z_!d&OYF`sqjE@Pri_wERaKQvrESv9&G5w6t8G1mvtP`;1|Qr8;9u)+k^}`Zv+p2skhin6xjjWF zf}iF~&U!hmYwuh9L)>S}TDH>FVz5V+lR~^XKPUD^YWj zq%JN<3#FQseGRaylf4+99->eAIi-k;r#ANMcjZ4#rdER+h1m+gg@gq!{@kWIs@8-A z8vPu78MYQ6OL;ogj%a+YzjuMYNUK)!FE;c`OP-q3yVGljwI*rD57Eo(A)>FeA8{IN zKFw0V_|NFrmWk(TVl+b8dI(A;)~*&L7#A^lEJ=Fh=3Es(bltx!lxA@^XCGGBO!5y3 zBOD|G%tP)x+3E!Q?$i&_v3aHYPTtAGpqsNC973ZDuEIxH4w7-!%C-xrt{j@qV_VN9 z^KP?4k8nc3JC{SjYlq`!LD=u#XI?hdfSReQPqiec5!URXJ2$QbYZ`1aRfZS1hqMdo zR^kal1ktf?m7`v)i>HkKy|OI>I4z>G+i zuTx00n9T=&|5Oz$XCJCzRqGa{A5D)yP*JKZc0WO(%WVDs|mw!-nR=UsXG#Gnq28834kEk;^Q-)RJbEO*- z%iQKT>r`(|#zp(nJc!H*wLxfqhHF9Cb={v9`m^u?<(Zn$@d9BR^k>Z~jS)gbW100v zi5z>1g#6V-oe~z{eP;VG$H-9uV^pcZwaJfeays52$cr=uJEwvFXzt`>ecc)UU(Frn zZ%&dGFRlIR1ljggUv!?2?$<1p;`T^hwE`W^} z3asxjDT9Z(#nF?CJBH2y#0IFkQ~?ytno*n{0%95 z%kuvxd7}m;ZzBIn-bQXypoI0VByW8oOspjqD*utZ5rFG=CIowV0T7qnDZfk*24#~Y zGN9xx85TCI`heGmU|1D|dp<_T!XSE4X)Je`Ii5gW+=-n zquT_Qr(Oy8ZrOv>mI7M2Ox9o)LssHQhJ`SH8ZZ=Kxf7)0@sgp)%BN~oZ}~~{Flhl7 zJQiG5UZ~diBDR!m4(V25uqT!R7DO2}ASZg@@;}8*N`X|u;iTa?8Z>kgSr&&{VFG)R zBVyRx11oVXMjqk`qF2keNvz6weV(ns^h=}x)q&l(9C=3V3F(fKXn~Ytxp+aJS0A(i z{tB=;gWdmO&?TCTl*OczGqMJ|(l9UZZg6nueL2LxP{u9wrT(=Hgclk4h(VuLVf}A| zOM?)QHe3FbDCA7TdEN*9gTEBosJsWfDU7Vc>zGi#Se35{+E(Hh7t-zh34fpQ)f>M@8_?g92xFzIzXL?#vple)&*EHS+{LQKG-+S%mMD@f9#Zu!@;;LnHAPuWZefr(_Z4gBsjaX$oJ zN;Sn`+tdDbD=alzsPh0NI6^?Q8!UuI7=(-mShIK-d^Kb4DC)d~niHEE?| z3*n%nzIg^H#UKNFc+npDiEG|@d217CfmmI7OZ8+Md4JCXC53&#I@Txs<73cnWfD+V zB!IY}RmXp1V(n+QG;yb?-*@`TAOjvi>aGi%#8$^lpsiC2{4kPhGD-DTs^A-_d=RG( zLiAisiqA*(D=Z}yTTAvYkhI5TVmqpuCA7KUx1VHPf|cFr*yy^O`-S~XP{mzqW+^O; z+QNUHch3Jp_!lL~k!am|ld^-Pf7~0)&LWPIcO5iO)l4{% zsWo!Jv&yf9Lle4egH^j{J!cC2ovrfA5M5Ml)T+-c9!!HFe^Ds^1c~+P1Esxaux7iu z-Ys!reZ{4~?6f*VxwwhK=+17;7>EP){SS0{J@FsV30&16=tMVh%tzx7o_QnMXr5}c ztt_}aO8hyS(RN*c@1JklT_@EoY3rmm^-rD5B$^#q(BoU-F+E;Kp1;DTMrD8IGZ6_D z_&5mpruw+W>w5H@cxSFOw%IkjVV+HrswC_BeX9+!2^e}G*~ZaQB0#;IM(_Q*jdvQ& zLQ)cyLuc8H^s1-W9EOXQnhU*LxP!=-M=NcMNeD^P^MQgaUX&>q9Ib?21ZjB?TK%o!_rs-MS?-;a22Nlv^h zpFj_%h04eNv>>=P)7fQ`S&QbSVZK>y!m0*ylivL1ybKNKys239<<~F5UKX_|w-A~I?{6$=I!QJ~g%a`SellhP`C zY`~WC4X9XslvS*R+gxK{a^o|9hu^}@3-)=K!Lrd}UHN455p^YS|EYt=bkQBh;$^EJ z>X~kg&)jGa=9oHaAYMFkuo9ul5Mz50e_baE3@;(hs9hD@IVDQ|q<<2SezT#9E}7{Q zmML;;vmZ@sKNhBtW72=$dOZO<^$S%!!IxxlYbtVT^~t@kmFGcC=Rjna$KpM8S?)u7zc-G;0VUtOT zVc~Mw3!#%1CH-zU;Wv73c(o{tz4N5_r_YvTU9I_Tn+pP{KxGLvaC_lc4op9O z5B9k4q zkdIzI#srDgm&Jsp#frVI*O-w`pev3f4+oV(cK z&7C3^GD(c;W&D-e+fb)67MIngcck7s-vF^Z;S-;~Q)=t!=DB3b&175>^K6eC7iAjI zaQf#Gs81iSDi*iJe72IQvg~rg=4fH>IkiTq$Wi=m>j}iF8kx%qD*yTCb{*+VcmXFR z->so7jUb6$F`GmK`h_I+dZ8c*>%Sk{;0=E)UNs2*KJ%YG+wt?a6|G{l8`>2Y*WLG1 zaGlD^9zICZzkj09-D`Q?G}V6O_n_Ui#p{;&UXW3WJpZ)06x`taG~+8u>HTZ>pFTzF zL|<(wOo|@Y-f9#fnJr!OIii>QQ8u`GstBkO_>{+|_!z`rKm@K7dXJWtSk&+f^PH<- z58VOx1y#Dy6wUC|1&KKMQ%NO&gSIY`m~uuRR6Y~4LRKLOW-v<7JYSOcOz5}Vvm)LZMth@kZ-4tr33V6iaqg%`MdZ^qzV(CDTcsB}q7Cpzx;g$=Mch_t~_D{m87 z28DN2q8~mJycbv*A?8Wv{5VW9rmH>9-U_fm#xW+o;vItrM8mco7KuiidUtg@GnYK! z2L4%HxK74>DLyS}GquHRe?!yzO@XJ7TG-dF!SZ${!c`_G>zt;2SQaiwVt-fu@$bYJ z_o|_!cRZ1|9ZC~M@N^FRKB$e0&$pQDi&#Bta*-21msevtgt8LhrIFa_K^lM1T`(&X zR0fkGv7S33UCK~iJlI}?&Z;_YZzk^C><-nxijVW9l7yCGt?v4onz1*NQp?B3H^wLd zWAmIN1p)lvEjdu}{RP+A`4Ogitz8NS;^6UHqzdUxgv1x0F87KM@YacJ!&ceI@SonG zyNcN|@@IqlKq?KFhI^x_Q>uS6Ne-@NkY7rUy7*N*Hlt{;dQ6-{2q;p`L5ZZoJPvEw zx&F3_;n`;I(bEk)Pns#DvH*ho4nkBGnGSxtf!wqMsm#dnN9 zR@1hG61_5Tif0PB@As|7ogDhTbq5iyqmcd0sQtqK(cV@>bk*2yw#0X;N7K{)8J9on zB4Y&#QJSFP0v5z>UQx+E3uPTI$hP28Nc+~Otrs#kvV8jr>pdN~!g_l^the*`Ue7Ok z@ri<_D0dozlbW)!ni|!s*N)$*@OGr1S3R0GP>~JKwI8R6I{`d+c^&)0oUuJf`1nQr z$s%3P6p1qGhGnc8;DQbr6uP$qiR?E3R~=5Ho}DJQ+xO_XG11ayl^81=l5@GyL!5v56j)*BX;X6?MdZt)t0c}kxC z?ZLB_Ia@2=Nyc=<1RG1pBMzNCdv24IPC~ovqN72F(W&h^otGjKBl0YP@fR8bK&}~4 zBHYGcjzAa30hR9q34MlIAgb(?{1MAPqpFgNT`&;;-D0q`bQ@BL1fGF4FdC9dN^v&J-DgT{Zlkm@ni=jz^OH!5OA6oUO#R7B7x zr>uj^&4kS%MH#`#XP~Y}159kd)zrVof{k$UL$stR#($%39Yew0ru%@$hQGwI|DX4E zKa&k9*sEGMOEO^=q(0+YptaE|qjfD+o>v2CFKYRbkZVOH5$nB4xxrPV-X$GVw8;It z$YH|i-nq405ljF_elA_BbZ(9Opzqf`AbQucucu4?(;tx(bv)!(Elc=n34tErB+{+Jc?6`G3H z#5J2j$Ug1Xxn+6;i74umjr{uO#;CMLq3mv6rOB#ibPlf!Kta0>i(TN7dHRlpP}B#a z5Ds&R_n59tp)TRbJ#JucqF#S)Yd9Fqr#c9)Qx-|2T zsx*}`RDt>_;DjD;m?+E5*0&JiZpNi(}~HKBafjs0WPb4l_kzc zFZG!yPgGIBgisdexoEyJE@TX)dF>n56L+C6YoyJ_9N_)Lj2gtAj8k<^3U6$8ll_)li<1zT`7XxBWzaau&6$C%hL&xvUBu8bjLPed67$~Mr8 zU~5b}|3om@WidTxZ2I&)M$=tG(_{YuH`2_L{;LKyZp@_kxuy|xTwQD0QCtXGCxM?1 z^}8zqV-342)6vnm4n9tD)R#!easY!KLC)DaWbCWN{3Vj*&J(wfW>f`$_a&e1w0D66 zwfn^@?+j;c%p?K2fU@{L;y(L((J03TfCv6kXY@k`U!p5S4a{J~^yP~(-t|p|F*8L2 z`Qj#(gD^4LAC`9Yq&IS78f&K~gn2aFqbDln3^6R` zx0;G$s7*vUFtjaabkMvPV*RTb-DMwXmGwm;F-1nxc#YX@!A)(k&1~^+|(YQ5)uehnFu!OUJ@$x>>oPJ+n1>_W){s zJ|+!Z8VNNEe73ni9sX&LupXVNSLjdMrn|Z#NF%8qhrCUCy?jV}6E`@=wP`3q`>>RaAzW&K`;SXMEyiG_cw^LkoP`LPscVsZ zL~rXly4~Mgo2d6?E$FqDy`*UaUcNB;M(PhS%*9*&E-v==VJ}C#S!gq1A8c02$%WZ! zs#4HOe4X*@PWRlgcs?*cR#?EFnVfSuMbMv#jdOYFL zNqb2KLU@U0XV0J6c5i<9WNG zuWfb#d~#P-c{WgvmQ5lVcK$2TQVB#G5wSqhsrhXj>a5oZdib~@5?sJBf&|nja!$GS z`151@%dDJfpSp1@;CvU((n99iVLXS+a*MJN&jzkHm?&k4CED_5g%N3T|H~tcsg%nS zObj$Gism9rgVz6N5T|Sy_1ka{Cdu=eEYZe9D=YuIaH9Bu9(w-W&-%1UmAmknNp60Hg!9G%n-+#QwfW&H z&($XyA*h5~&poKwfo!qFk&#u19CpoLy-rI*4l^iz$syrsZYTl+w`-vE!z@_rH-3cA z)FmS-O=e8^ER(^XZfEy6crR&Y{=-6vLr^B)aaPBHC$Ym#<4>E6J@uhlxaG zD5|M;zeSMZNJscaK=c!LhyLTvb?Zjq%oGRsDr5rqCEU}Ldg@ZC)Wde$u z^S@#L0N)b$FPx9hQW%^>_4z+xl7w84BU={0*NEp^+U;syTYbi#_2e9eX!F0}e4=)t zGkrZ1L4ZP5n)HxV%yP%g)(^4!1r_(P3mG)EUxtkwm^TMn;6doNx9w!E)WKYd56Ya> zn8GhB&hvzFr^5ypKYcGg6u&`zc86@aUO9K!ui)K6-?)Tb<;(nTzJX(h#euB#mP)M! zUH~RAoFux+Gh@5M5bSF0R+XSUgLLg6mln5F-ZBrS&X;bMU#^n0x|6zbQP3xeNOf6%Vt#OKV;cBIvib@iAk zDvV8YvsdcrYU^PLNNoYF{}7NRm3iJIxDLLj<`SJ<18y`%+7tU`wJbz{;Uj#Es5bB% zGAErG=fI${q0aMDqaz8iVN%kMi!p%I_a^Jb#up@5duPCdyz@=jkqnd#3|4o7Pzm7B z*4@AaS*$jl9?os_82r!oJs|Bx3W7hfgKu&XuIvSNGj7e{Pz(gZ=2*8T(q0g3@YE>O z!{#L*NqWV^!U2djOc8(+u;>aTL)z?Eq8Qi@syOdc?lOY-50E-V(o>N(B$Z&(Pv?fH zn|MIR5FK23-(GT!kV~D5(1Wmyyhnhc+6~!=RTD@f<*^duPt66&$*@E3SN7}E1>>0G zzr)En&vn3#4Y;Mr>2(VCPE-Q7?rIH@(c5Ybl$S4JE&?)O)T4F9r%xxOb-C|n+v{$3 z{<~{57^?=nOoCTJm0bH46o7g7z!Q}~E!5A$bXx=I4W(i^Vapz;MD4V>Xb>{4%$bmq{Il(w$x_We{ z!iG|}-Gs$LI}D#ZwZ(ISPtHl{!4VqCSd-XyK;EA+BiL?zan9^Rcj2}`W}jCG+UnlY zRTU)g$=lZ;-zlbaoO6`M`KW9x@p`nwm!bONfFjSh7~nsMy0Fvt8IZt5Wz95~OG;RAYF zUn2{nYxOaP<#Z}j7l5!kY9yly?6D@-WV+7=7&=`mfVUQB7Iou%%H$n;bYz6LZk%^C zmv$&$NMid!eam2`3&o5O_1_2EzBs1t(#Lv_F=>mFW>`vkgC{Y;|+mVqidB`x75RY>-$Fx{P64 zG&&B^*OUA47g?u*>>BV*QEfRo$j9f04Pcs|RYuv>7?1>RUpy=ik^ z1;=1BPTjF{)F#PF-C_DUeufYg`|qq zWYUA1(%{L@5usCm_>P4qVb-$vxZmK5CZ)0-1UzAQ1C~1sce}y86Oduw<1{~UINm18 z*vFIL%OLHQWXacsOi)Qn3baiGe|-Hr{{v=-qho=+Bv@O)0O|!2W*6!?$L_X%CB(p!tQ%oE7Y*1Jb-;;t!|8 zf#?1s=$>i*tLIUqO=|x)U|D@XT^+4xiOweCK=GIqbQ)qHT~}tzLaT=vNzP$0G{$lv zA8G6yKp;V?^qsE`< zGCRA!k~{#!r>==9El?D%1P&KYq=3A`znk++(}$jA%iNZl_${YfR&MF{@^_0RB06!k z!p{2a@VwoNxDBqXt=6w1W)q1n+AIMCXwJnEAQ^0)l&(m8?=%LLaP zkl-b20A4~w(M|$0r#*NI%mCW|z4jzZW7y-zVe+h)Q_0De#A*H%V#`jovJy`&vG0jf zz^%mpIz^#*&As6J0iQ0$rd{lkiVQta{G|GtziNCL^M?UaSoynG(J2cLGk6r}Zl^@? zKR)(ec!;(tHRVOKS3X+yd_j7-dJiu^hm~zIlcvqQDI5(bKp zz@2T0tW~HW_*lu9^3`XR3YUO6 z>Oo-F9<4VR%ywiYZ`nY8>oaw%Lots$x&!f|9H@qZF2IFaDln z{wg_*pWmw>!58Ai&*88-%$(qI-hdgmB{jD(n)XkAc)uhvg!G4PK!D(CRPaDo-+y!wHPA^sS@&eSOTi$lh5 zi84X9J0M_nj@m375xlFGBW0#nFGfKHhm4%%$_K|iEQzdshp7Sc(f!O$cp46cl8FAdlBYoCgo~2*wObq9IlR$u!CpCXTII4a!q_Ye|%?E$}?PL+z5+ zI8$%EZ5^jaNX5*#G-?;gUuC)WjgjR60f(==Bn8oWt2~3*#7IH5^CClzU!uIGF>4Gn z4BIHH?QZ(l3(neC-uPn29`F8>Nw;^~3d4J&^6G~s&ZAS*fK+Zj1-=-j%kfF(w2b$^ zE5kuJIrD0wxB0RxMnDk($6_~*n*@)iz>l`$Q0(x7BQaJ9d!Nb=hL?O|;{z=|iKP-z zIiTFspQyUPD+-<7OF~!&WJ3%{b`PX4d-KA3t)Hbkxp7D<2Ao-;PbXBQ?IZE2_G*-V z1kj+`IH=>oa{|9MLyj@95CqmUlZ=!(jW7MjQIo=>{6KwSj{!(v+)}_VX_IQRztr-t}MuvN<`AemAb+$YNdDS2_|er!XuH$L#CFQqz4}QI0wL6 z@vS`Q)0=YOvIQf~EZ#IY59cMM2{oB$_P-pX2lUd`5^R{-l@rpoSdEXKB-awv>^`zB zjjKpEY7xb=Al%1KHovxy&0J%)Ns%H{c!FZMU0Z`}VVL)lz`=iY(;6)K`ydo=xJ-E$ z#MOD$|N58*-{Z2>0C++DZo=qK4PI1&I?p%^Toz3mTnbB$z`Y9wQTz%i4_}7PynleL zGC7EjM?lP+SlAssJw{UAvq+V z#SG3Wi;?WBz1!dPtp9u5KJ6ob z)0aq`=TX6e@!s_G21E0_D$>J7-`!)-mBMC{JPqm5l{0rDJdO&vra%SvIPCJ_{u~j z6P+qedh=ag=MU3tVtn7j(z>m6x65qt!$Hs7bblTAF+-e@HgLW^O&4E;la_CESq^_(A)}=h3n(tH88Y%7T zj$x76HS9v2>*-}Knnj|b)$fc!G8;;Oej7}LUXsIBMO^ub%bjBAJiU4lrCbEPUT@^5Q$WhFy!E5f9+$N!zQu%ZAt-9f| zyx>;}sj1x#U~8C~a~%;I&tugo+(}Qr*PzJ>o}{&aN)Z`p$>+M#$iXLIrvI{?rKO|z zNEzEIk6BCI1Jbux3?d-g0``r$rvjC5DD z`pMm1i-!-(q6C1)<*3tpDTCvn0#3C-%Z+UU_MObm$H&LZYum!R;9Mh2H27oEAp{7wsBt(AP&ffB6g#n=}I5MCM;p+qR6<8Zd?+0g(MLJNSlRsqg5eIE|KZkT3>%t zDb1sUs6wsM=XMAU9L3zrtIjoH#r$y2-0A6RCnHr-F1Now#WPD;OS+o#tDykJI-nZ% z|2;;V1n;Zf|0;506$Kh|Ek**9lW6}0^f{S`>q1KBk`PdQWt&g_WGE{qkl7y#hLxzF zOLF~Q1Q0j6{*&Z>_Qf2#$N|=>(qwDI8}GmPUgR*aXYtiRCwn*|`tNjW^o*WgQ6{s4 z=xbqcxX`DKYG13De;xPn z4wB6tLwtyk8nc`JI?3mkQ*|xNj#L{;os0H55Sw*j#}_>=A{C zqgYXXeiL@|=~OHm8MvZ9e2yL$0B+7{CJ~E^;{mo#V*vwShKEZhk?kG^Xr_5 zVb=3?$DRKg{`VB!(tVGEf??ZqSPNZ84}2$q zyuI9N-8L>uD9$E=Os!f;8EKjH5QIO3_8|Hz`Qe-}bQd0N;f=lKHF*JkzcV~Gexu|S zy5E1qFVx1-Rv)_!2UFEtzD-(q#~OM~Ghry&(l)BZUdMcYYpoSC?SkIu8+S9sWZemJ zItE=$oc8g)T6w&SPip5SJ#GjBoKbg4Rw@AJZy#)O@?&pMz`wv7|9>jrxm++SPw%nD zkoVtOJYlH-am{q_S-4FFo*=yoyCni-*Z4(pHo$~B=$~WQV4*e&zL@-&;zl-dV7YV` zj8FF`#>Sf+4<>E6pWlouapZ{uDe|U<#{AAt{idonOMDtzyF(egn#VGJ8G}7gT3(9A zkHgV~lD2yuQwnL-%+^zi90LAA#}p9DxxC)j3CC_*Kah0}xU_Ve)89{?1Ne z(YcB^XxgxRv&ag5``TgR%tlOU!G$nRCVrEj?+QS1Y&0XrXE1S(%b!U;)}#Kxvwcs) z%u#PgjWN36&-%)o!q*q~@V=3GL;sCy;AQS(B^9jk)IwF>eo)H-`t zveKx8;>KG)`fcbY!uKECW_brE9oVw*TMcrEd&zY1s8Q6bG+6iSZ#SmA6WKbQc@s1v z^Op-9*gp@>4}@o;s0U^s`d+43%(hx9xOT$B_Sooz1hrtH`CJ?~|UP?tiv0LDMFpo0aSEvK{Lnx5Jhk z=+Yd0`>pr@l|T`NIdqCPME<#R)4nRr5h_LjEJh$aqQBn7eivek;|#aqJERQym<$&* zVd)8Vd~8Bn6a!IRmAp6eD6$!P{Vnd@wd|z0cg7!x8S=I^%gD?d`6WA#+1IP&r5prLYyG_E-IH7Eu5A~ne5IeW zMKL9C{d;m8s}tJE9#uAH^m*b*gcb5pPQLfU`ax?Il8*)p?EySbLJ0ti3LkyXQZ7*} zAxW=!4GvLhjfns2blEognli*pdgee2OKgyL?Wxo}4-S~260eOPudEtK!n*ulZ$j za1oOWKLXfgZKB)CR@w8U+6LOZ@4YEHH0)kKOy&MN%kIRo2x)WtnJJ5N$$5@uclc;? zDBCwYcP9N6fq@Ef*WL zY}oWLjS}bDa7z&Le$2rpaSr)-@e&rEXQ;6NLO%HH_bw>gus@g1fU&CUBk$Cfsj<+0 zg*6b8rbfrc#&pip^0VwE!M4}^H`B4z6niwCI`=!F8B>49YA6+T#YA)m`dsyjcfJkm z?G^a8pPV$aZ%w#wE63YZx%3DA;yUrYd+g-mI{n#L`B^l1TCO5XQOR{TP8TIwIh#1| zoa3uktC$uy+S}U|DKHcUbsS1@JA!j1Rb$r5bUOIyX#PoB46gfA60t9NaV5E$`=@te z;00skV?${Bj3ASJ{ZAKQeR1z~uN)G@KA3Nnc%Rlluf)X4U#nV4NcP@l_6a4L?UkBS z0Y)hwo(UB=SF^Mg)m?GazW`l}QIz?1u?;WX^(>XhP_Pygautl%QhC*Lx34^hOX23& zX$Ds%)i&#It?~thh@lrZ1XkDj?*4GNXt_os>mBrviIR;ec@3-2fVVc)!%K5Sj0m;( zQ~bh7wQ}4?O{8bcy+Sm*6X%18%(2}%p@?;PwQs|0&Qia{H#GdofS?e{)|ZNclb=CP zZQlAS*s1;w1;zA|&`dcXeJ-MTZJ$l1bD1|-@*>QYK#O94?E~{0T|LJnx=gkpLoI}&P-r9PFAvhlx!Yw?`m$J z&5cE9Ht6o`W#*t6yxnsLh5i%jbSMSQ_$|4=cZ)sS^WN^nsQF`>!HCMi>#Cb(CqHud zsemFiEbsFIjwo#sr<`x*$jqb{zW_9R zvv@P;H#pZakn769i{od2NJ>P%y$#$;F78R>`MfJ*up#v3CZ*;tl-Kltk*mL)# z$h3szW$6k^omFGmAcl^Tp7R`|X}kp|^{HAK_Y^oR$1>iq7476HK6UfD&8#g@dv6>D z_9a;|;Rh!Q@+R>2@^-Ip7TndE$t6H$;sVuv^tjH{B{2 z=6IgS;7E@Gkb>UcIh3?EEu@Cy}wT z>etM9S~TSl$#Sp4$%2h<@QnxA$|0ILO{`q%2xeJS<&|WvlU9qxjDWJT-}G|72od$O zJ$C{+oyK{&ZnVd$bBH|PcA+Exq}axFOi-dHGIflZDsEEsQ)=B}a32WN<+`A)uJX7? z)TRKDGW4L&*DTS)2MI&nN>Mxe#QDTgG$&QCs$|GnaX&x6!<#ns3uS5Jo@I?k&i+jd zZwRrp)Q7tG{=aY|P4DHmD8+f-1X1p5>r0{IQ9%!O4!@yASuBmPRd$@@7`BryLDf{~ zemjnYiBKY(2H25(UClihgU+{$M{)FWyvD21&|u;vJI@GV?{D%-=vc^$WtNv#W-8dACN}uw9}8`TH~(tg z&3h#fqANC~3oS+8e#GAPnH_&h;qCJ|{xOA{q_5qd%!mkL(5q0J&!7naj_443jto$Q z#VFB*#g~|LXwvkYsUO2M0>JPPJf5otPw5!F(ysXmp7)-nRf4Vux_;*zc{S;ILris* zM}=SECw6VpUz@JJ24YNd(5d}AO(Za?Aj{LWPDD7H>Xh-{sU)J;{kFFj7OBPQKAk*L z-5+?Tp1|#|VO0Gz&{Z??`3$Db`?VrqpV)o0%V_E3{??lVhYq}nm)_vnnw{wjImt=O z=p+3Z9^TVkvPZp8nbxMmM&B|ei0v3u-TL=te#e%ZjpbN*s4B1(c1g*!mY|buYSvNU>n@@~|U$-7BwS1)K%#pS3yXztNEoy^idZo!^K<>)G zk6!vJxaa-MEf5PsWnzuz5Wk3bJX~0GDo=iL3^VjlB{|-kgJ8S!izmj}n zm0EZIIE0w-qjf)RaLGN7i6}XRguM)D{`xZT{@+mvF3a}6qTYkuSIkv!JjcgTV@ito zOdhCquAdN zVJA14o-2hDeQ%v|Qvv)|GHGx;xjPf_J3MytO>(AvE6kAa4d$U=&{Iy$IoYU!Y^h9H zx^T;Em5!@QeTL9Ntxn0FVD{Eskny1#O)&9A&<4)~5^aFP^&FNBzK}0|ePQv)$SB%> zelncoK$E-E)#YFqnd)~(9W!!z`;G>O+rw1pBK z4C6C@qQ2P6U%;7v=nJ;jp>Z3V{gjwHnE-j`huo)Y{+x8{GSH-t3$lIOMhLPBzFLz+ z{fP{xzevuPBCF)dFi2no)e24SdgC7jd>xHGKeftRG8aQ!V|~(RXU8sAXPjvE4Vu0k ztI?`k;m}}2w{`Bvnz;dIlLH_i%WDXxMS#NOfCa}BO68vI4pYhD7>7^=DBY{*Lk9-l z|4FrBfi>ctQx5qBa!MfnH{!Yc)2$s2t(5C(2LTR)q#}6OR0|eFR2b>7^&dau5ekp+>*oZ`6lYz z$VrmX>m9fu8Q<^yyD-sl5X|T+|LOZ<#DyF2 zBb2-+A8Mj&K|aR{b2Qb#72qKCT@v;NT~(D7Yh9=>sDF%P))(dmCpl5lw`ryI{Wp0P ze9N6doBlrTn&_Mq4Lj*@qEHI4n66ht`#A1)KwpzKoi;)L#$8!o4NCE|i(Phj^eGW? zVg2$Wq^uq1O^mUs$i5nh`iIx~Q*eTpg7gwDU-d);WU)n;wZCe|2z&tmJH=7rvN{y-<1XM|wyqd3^3i!f8HcMQZ6{>26Bg zJBhnvhOP8hRFZ~<({c{5J|sat!POQQ7Qyqs1Gr`hTzNo*$l=gfLa9vkE&dYv^s=j! zNJIcWsMRf01$mg%E9O^`TBT>de)RZY{Mg^=z?91)QHEK(!8bqG zWZ(-$E;eo;K83spv62^jSO(Om!Oq9EIvI+55-WkP>`480zYIwxPKBCmImc0 zKqS~TKxkPTlo!1iOh9m>bS06;#_kbmOHS~ZXIRO|(py>Z*ys@(t{ki`8&G7R9l>TsF@bu>jWhf#-KWCj0E{=JctM!Y`*Bnb)b_n}?q_Wc5 zJU2j$Vh$J@cRE5hvkEQ5;NB)9Qb2{(PuS*X!$=#0`j@+Z`XnWXKYr(mU8)O@q##58vI-xF!jcW6JpG9gf zRgnW#{YI{qm5;(~f(}oi>nF>9~;%ZpSs#8DPNwy7+3EMO~s~?BG#?;vEa6M{x zasu7C47{U>)o9@YB2>tCL1{5!jeE+dvJ4gO| zL^`$3HzPsKx7BYk1JC9b%v$+dDIuUoDez(l2CS+-Oi!x@kpaJSEcZL7C(3lh^lS|H zCKSNY|9K02aGR_)=PQL0m#1xe!O`#2k;#9$d=JaL3_RQd#pbY-7?mGlb`3eDa3!WKcLfPWbxy)wS+%4tsNrZS4F?fS&)3;zrW}WW8}^A zJ5#>f+@n|g+kf7wk6>NO=eeu&BY_jW8!IejOR(9!iCJ*}P0r^yyKj& zNfhMkGioN>w#I+GO#DUfY5aP@z7zth>P*;i8xNb>Qye(X~jNi~pgJ=1t9YtgTujs~yE{sk|YHnvNjV39Nc_He@;*SEJk?jZ?0_ zg!A});9rqQ#yf4hk?oOiqu#Ugvz_C?tGlrEnPm^JiOJY_`ExV1)phWA0)KZL7CagY zCUXhC7#S32{75@I`q((s#@j^d+^*H|vb;fSdoTJuB2TKLsvA+zX1Xt38;$S5O;0Z^ zp@I=wU``I)QW2O2U=J^@1PG@ha@<}G0X?CEhg#b7vX7sZ2!2mO97nTMQ|q;lb=Bli zVV0f63)0~W)z{N$jA#Io_k(}6!)5=tY_8y~Gw)VDP(_C7OXBWsP;*qGU34)W8Vh#T z|A6r79R}+OAJ>3kv*te7#_Y_*dihCE!TJ~qy41fIF9E+At1S8fq1oLc3y+wIsx4TM zxe^<1%S-u=JIf>vt#|tkV(!Q2v%joG?QNYMuP(xIk;Q4t?tRqb^?Xx3qg=FC;aP}! z2R(s|nkV^5ucSrIen*Xp2!4KmU6+@}=URT|ejx!;Ncsj|X2Fd^d)k{+)<1*|lDZbR z=NY}I-Z?LjJ>nNAinZjl9Hqi-H-Bwehs4gfO1fWRbCkxluZVz8%w216LM2lY=nAX5Aq8#k#&Oss#)2O~NK%@W zgQv|x~1AV0=8#UCrw|!C<)|F3@Sk$CLnd4opF45bZ4$tuvk+ z!AhP-UC$^8F*8ztl{0@Ln{7I(xd`M7zg=JHxBe#h`BNzI(ccKBaAHkit`q^K)y6Y7 zBT?)2k6Q!IYtPv{oVUJp%R2(WZYqq5^&!k8|*=PY}ymnhw!KS$# zLFJ}DVw5EEZ?PG)YsUzjo{hgD%Bo41pD^hN?){um%rp>Nx)sl=w?swd8U2!A==MF^ z+EdX?EKe6F*0eIe@C%?=$&=Xf(sGrkCr#D&F7Kww#8Nn?{oNi2|%iDt~ zKkZcr`V*~6vWa5lY`rT0JOFGSg0X4Hx@=J8ufF_@{X9191)-7=;F0WYnOlNLuyG2v z8~iEj;Is4v-DWPsWuWGq29tGs9P;%%H|F;wN*Oay+yV^Axkl$XGl7X{pUB<0``}c9JuQ%K8$=Tw^>u{URs=2aLrJ98GkLGS- zL8;tXe>O{VFluozr+=o3#%CKt)5*bmpIx7EJ6m5wqNe(Z$bh#y{;V6j!=MQS{;vg1 z>=(~0!4>T0OS7C?&_Y4&a%2`qSBoYEo|G|!ko^>eg_*T>B@;GR_|=#B1S0DWzuf`CKC>%4>{b`ZZV!WMJjw{+T)9gcqMb$U-vkwht`J;#+L{FwPgOVX4H-%r}wS<$`$W7b9RCk`si5 zgM(QP0OOGwr`3?Y9=jvvRjrxXF)6h0fz$O@cO)C3hS|?^vOm9j`Q@JtDxuf*VDT4^ zSylB*D!^{~>Df&+Y3E;bBmUoGPEpz1SzXUfF2INQ*j|j#;<1bH15io|54B-?fHZC% zJajq_Y1_Dd5e0RuiOc{mPy0Ni&1@vn5K9MKB$~Lrd1R#MNDDiuqaP3OB4+E*}nS#izl@B?K#LG0xsh<8Ywd|7cnp4EVQlxpBT?W*%Y2* z5d}NPu3#wJ=jd6kM$@k_E~`RtG#e)rXc{TMlx5{2dif^j38Whs1A2k@r+Q=eH6aqw zT=0Jj)SGC6-LK`tQ;^&sYdgfFwk>$m9;X;id{R#jN}E|QDr?h6G0i2fy%sFZ3AQh1rwp62pT>s*Ll;-k!MaG z|7x3D6BYK{(%pL1Y~wY7w7xAnyaWDB9Stg5!R)mb`P&d=JP z-I;*Bep}txs30$|UeREv5#qpbD)YAt%(?}}JkT4A^MY$m0S;I#rU}>!Fk+2E8Y=zJ z00{yANIqJy3%Wc0jtW(m4Qhrs3@~c>LxB(*QaQnhdc+)Yvy`TWQVl$gdPDT2vjViifN<3e=<#0SGb!@P*T9#!fT_!%!1;+Cj7JDG35-j%f|B zXN|1fStmR?4{#AfBwS{=N$daeSD8 zRQXvnCJcMGPXCj7MY~i;=T3Kua5!-{jJQpt8mKlGVOGQTC%H&NN$&+49o;Tn{ zx8eW5K(o#K|J6wJE8s`Bc)ty6QiwYaKhnza^SfXYNMW06SMxoUx&vXAHuuu1dUs}T zMYS{vGpkz|Iy!k5+<`Ad?KVA5Hc7S*Ct@Oq?jwN*3)5>NGgaUXU3@@7|A7ZdX(bUs z0Rb334#STMH%9-2Wqyt!Kjo47SoXpT9__`@YM5i)H;&l?F$&OCyhbZ!I_$+INxXoL zba6CVaPpJMKSn{I&Ls(9SPtdmJ~pscMGF0yp(TDxm7hqr4Ta|@_mTItG!3sC3O=F9 zoh%K#pMZLl+?i^x*pEInS3$~tDie&m2y7WV27kMG1SJBWOnn-$+b+iElfJnE)hS!cA=J6 z4%WKw#AJ&snRpgD@NP13Zi@zmd#5qQ2)=;e;f2FfU?KKSK`e8wZ`n)+@b0~5C)yZ63 zs(HLC3;OiX59vdoLe?=xWK2Qk2_K4K)9Uv#RyBQL5gDt0jJ#SvYHVtHPvy(IZvV7( z5U{cP6LhFlb)ML?lqYt0WCY?H!X(G!YS+OFa4{KziwS(mn~_lhyKt8oPl6Mx-4nsA zF&8+*`4j!{SmwE$BX;{gIR`gt7v5{(9mu_cy@(%WV3USozsz!3{wa&5Z2jI2+G_}? z?b*rve7DML=Ov>Q9gBUr3s*Kyra!zvX|4&+_vMDaj0$%8B&3s2;ljVK8?>uxIC{}* z$I6W#-r98q8O`D!YdWxbZRO5G#ym%_zh?{s^`&r~V4@TCO#18V?-UJJ0(M}3MLfL* zL8ZB)inK+|2J5ndRCHHatu_`BaDxH$07y>KbCGOM0@-iuDT7{L>Uy&g68{?lXF|wE z@Y-~?C5p;gh92fyhTc2j5>VjcJ(&O#80{sertac5i+VIcTD`qnK$G&DTw-h7U3krt z#S1_NV)PR5@OF(ho)zea&;K3LbvO{g$0?2uN;7JgB)g;wxZUZAk8p>nV&ewa|K`C` zw={)i^_7iHRgq7D?=9ga>rog#%Fj<3;R9`Nje%rL2)d$^-dke^jz7DJcK~-Zxghe^ z|Jw`5MFZ(2xX<&3$Zfcf-MMYetJ}5AjRwunCeN8rEi9ZxA8aSl3F@_Lwmj~~l_r~lRI4e9wpP|Kbbn5eIHdH)Z>LnlMizW7EsE`db4qgs zdpL1Qt!4Dng>$Jq=)M}b1o$-RV*kJu1SkK%<1-{O70nLat<_&+j2jN|rNs0H*k{P* zS}n=vFEGN%>~*I++Tuxi3B(H+DY56QC#oAf6G`kG1^LK?Mo^>*k`YchkCrtZ-w=OV z)HfBm?!T^Y;5E30&mD_POvAOz8`k&aCwLr%sA5&tW`66LWrYx7vDnWX5<_XovSj&- za57LAeF-i@1=JC5#yVgqo(fn_!WwFYEbjw;LrJ2IYmAV&CQV(E6OC;aW2<~AqS-(B z36dv2pU9_)5f)4ItXWgk7%J6AwuwR;kPOCiJ{0btX)Y9f`t+OEKM{0u%^l~p2X4Qw zl#^pvlFh~~=$u=cO5N&<(aj&@PmHH0n@BhCC{wYmLv-_P&Us2S?YV{=9mss%M^k>i z!82ChzQx)Fi&C10(}VtV5vYS)lAcezvujVPM5gaAuR}m0bE0t#D{Oi7p4aZ1UThNp__{bEI*O~)Qbj8@j5rUE7ugH z$N~>O9OtEXt79)`$Y zORx`gHFDyN6%gikFX|$d0t(fU59LqpStL(^L6?(BRx4>949*ZL1z_d{nAiW_?Fz8> zuv@*qiXUo~fZv~(YpS((qx!SShF`itNcE^G(L2hILfO+}?s#~cJ8y2gYfsdJo;&n| zL*{>lxrF2YTZ&mM1sOj&G^3(VzSXQ1%)=pH1hW=+Lg4TNQqLT(6?7sO5r(*5&JinZ zQLytG(?3_x902bd)y0->zbxq0G!OVqt(-={GNF`zPVwG8;aZ_qB^6J@Jo=|!I2YZv z@*CMbv31Uv#FUEhJzH2TtJt=*qnuGZV+zYRBcq;CAP0&zJifGNN$c&y*NOci^NVi_GMNWJ#TgFWN68j(puusn~fKv(880mm< z;|3=lgbvW$+q?L~G(_1kw0{8#GY$YkA-a`Ty3Fyi~%{SNs4ura70$UAjGlWN^n! zf{`X!o=m8pb2wwkdm7hAx^VK&3=rm>HJI!VJ?8{I9Be|*fJ7yj6+=}0_v1%j-^xd2 zjRiVR2GG>0a{(~9pKFb>m;ley*zsZ}AIsf_Vm_GNxzUM&Km0<5uog>k7+a};jw2af z!@L&@{aR|4NMM+;l?BzT|JfimS0h)tyybT9hUPtunt@5;jho^dJ7ia71O78*;-TKA zffgW=XzFIHc|emu2Ey8;M1s`=BbA)f?<8b-O`+5j*+(3dq>dJH(Dd3)XhwLSS3eqd z=-&*YevV1W2LuvRHK|Mb{4QFzpq)NgO#A6?;)J=m?~~tP1kthtj#32P}Y>FnR(CM0k8mx~{YWkOR7z zBI4)a#!o|Ih=tbB?v&~R>!*dTpxO**yT593kKhX7G+vE?V``>ccdFIE@L0$ob?dBg zLl-U&4C;WK7v)TJpo6IRYYS(ZZTV+=wc2>7;oR7x^U}uk0NGF7N@W$0%{q%@Kw|jC zehzH#Aa^9hX;BSjmHRwIxZv;Igh|+Q!=Z4i0)a{97+-djL{bmdCw?OX5l+GBuO!&0 zlY?joxJ8A7Jr!Wnm%jXk2F{tH)GzZI4B2te$+w(t5Pi5vNW-Dw079J5V3jO{n zjFS@zR`%bplP=A%qz&)GuBQ<;g``zG^#&LZGCG`Wipm8d5fKmQj&{Px>xKMv{V`R8 zQw4oxjk(0R&q?4D|f(4%4@inj$%%QAtXjFd)A} zGt;CMj8~_8A&hsmHn6T}1*~Ug?&Ppw?ySEAP21Avwz?V|%ynkje4mN}m=L?C=AIB7 zbST7*k}v=6m*90f=b=DhyEub^R!APAY|P4JjJ!3S*szV9sR!77o_;dtv;E znVBJ1)&%S%7a^V$`}N$eoi!>K81R7P9(Z=R>8|CbjOwlf9n==Rgcg?^gtAz>zH~(y zL^x@xD>|M%o3QDPt_X=2mcm>|Mr)`7`z21v-}>k6R3k?C6LX9Nv+e^^g3O$&+wORI zAmC7zKZB?ZUjDf$JnbT8rZw=Xn%m64T%*)w6;U$4$3O&1_e3yD=s5E&IK3(UAEMqn zp2|P||F2|k$)1OhnZ0)hl}Ppuhsd!xWXr5$94j&_h0L5pcJ?{8WSp$5tYdQ=j`6#^ zKcCO{=XUdtKV0X$_Vs){9*=uc`eUEe$Y>&tVo5gjG|ov+`aLAy(LTs?&q(;p-#kAL zItVf5pB^gnYa(OC*mgTc?Qne&44Lsm#!KoD{bz4qiG)hlsK<7xAJWWM@{+yaMYVD& zg+fF^Y=qAMFQv=4teDn2tfy7z0I)yZWu_l^JE*G`{JW`_4Z#`UDW@tXUXntNL#8+D zXlHx$xHcZVd;_ly0N^XmNJ=Cg^)i*63u5?Dko}do0c{n(le$7-%H=i0*7TGh`LvJR z05H_81jK#&wQz=>W??e`%ax+rF)ILxK{1J3S+Gsq#N1`lm|u6>*mMEI!<`L7zv&FW z&XuJHlX^En(FTixDwliGax02UNlOFAfbCs+CmA~bD5W21h%j$8Zs1uSM{VkxJavw^ zLVO$$NdVWVQ^3fSd-5s+5YNLuVEtJ8WFF;dV7A%{u9 z1tucOkyx3XvQ^&}3QPui8}OKOt)mMT^y@Tmzkf5*aPzLcI$C_Fg@YfQ;3ckMc)1B< zB_tWt5`fX&>^1OWh;|VS@%ML$;{omum6erA*LPESfPIYvAj2BuNV2JgCYGlRwqtX$ zJtkZoN683Na^f92u!m!y-{NT>x_d*t3X|Zdvc`T$h(TQz`pQ*Zf&HdDLR#qwDP)Go zL!iab&|V-|A~O$5ei!(sTt9r4dhlzdF38UvDT^S^xr$)n}Vgi)jWgeTSn~oD2;kG+o9$noxsxX^|y6OS|;1#C2$^ zJVAs_wTtMso`|-CC~RO7DGDeeP468r%9=z#uq2rE5w`XN;Sx zh@2;vhTAU#UHl_qMzt8#=Q1#Xj~Cf3XJw(kNXF>Hc6DFzTJM8w(jUJ}wV4oAdusD3 znufb6XwS!0ZyFqA27GEw0br!+OVCs$=br!l+x;^1jLmE8mjP~?1aUs4fwK-B1{(Sf z51~~t^f!e%Yf&nU-YOJJE~ZvDy1-`%1bVwwq7C}Wg7t%uuM7BKrsqLTdM5c$S!B_f zut6?r(P_6}%Pk7)Id*lb6M(10-jGmBOIO`?Mha@H{&%Q-zeA1jD^%@KQv)B&$L|af z%{vi%jylh_2BKoNLQ6qTxa6$4cHr11Wp;H26gPf$4CW=&Zha|+#XTD9YT4F z?yWJ^91GFyI@cezF;YTu;=h10tTp#4SV=bB_AgKy>x0;@O6KHp04p*uj_3ai1enL5 zFG>-%PTPA+Jw1fL)4#o5cK4)?cOt${M3Ii%8J*F|CzYF#h;Qf|@nSR=&a6HUbNtM3 z(T&@D`WgAj9$NL9mMwgUJ99G0o^ae5tnP3bwes-uKCwD8VSIR3BDm?`FCG-}76kp3SAs^iy(aBXemr{0-z3nr>XWjYK@F&-x ziD|ts+4<>_@o>|_*#C>o>95<^alAvYs!SX77(Zr&@B&pMPriXRvL*oy}UQlZ22t;H4Dmpe3k&6b#Ay z1)h-q1jI|lW(;z258F?*yU6S`6BS@-{d;>ifkR9uN2rXHl$4SfilnD?tZ%B90eR4- z4yI^3*bOj8UcQ(&LUeWH)ZM@@Yh%MaYGj9Irn8qv>;#)Hw9Q1 z`)?mIk0M&CW+LR&gTcX+gh;6nS$nb;ngYi%RL=|*PjtJPVr=^mwjQISiHRl zPLLlowpA0tv-SQSWyxsBreLb~C-F>J6{>OPe{bYmk5cBdK3~Fe*fcah2J)C9m7A@H|p{ zYaBEX4MT~yArScjuVbrq`Cd2qKBml;E^725a;DUJPAhsxapF_F4FH*bmdytV9NaRZ zSBu=yulkmSuIJb%5JS-uKpNVL7N63W4`+ZNqW#4j}S55J*f zyrt41n0-1^kgKDaF$OD!vXz!7TVF&>4yE126j`c=?c3&$o~glP;iT7mf8Wsj+J zz1QjW07y&iM30lZdO}$({LuOklAD~O z1N``rU<~c`!U#uxgHzHPI9mVrqpev)kZ;?4w5{b@ux}qdATqRUd^_#)cAAJS5mepp zYL69nje-#X2R|XPk?n~i%P8&>-5ba@I2=rl}Koc3P zXk7+Q_d-J4tZ@{`-6E8AqI;?H#eK=|vMZ?-R3Dm_Jj8|E0)~oh*VVi!;32Gw#bA-i zszeZ455h=b{4tcE8-s1?JQs|&sPc@)rpEYa!*u+L5F9)VHpX#S$?V&{_UZZ73SG4h zYp)fkIwhmld3Nbo?`S-)kzOmmt<2NJLBvDh_n}xM>z39fBzJ=Nuheia-oi--g ziQ@+#|SxY)u2+rTD9Yyz5$Pk-h^!#>M;xRdYfRSLd2QfB)S)$DpF+1fh zGS-01>WY|Qw~(B&)@R3$5*XO_g^T7HNn)P$+5I{J_1`v>vq0W~e6ziO2R{xF1_hhu zu1sfYA*QH{Z&%yM<>xsChnBdDV1%m4 zF_7);Bbt$=8`5H-HxN&@7fUoLAqP6{O?noElrA24X7SByTwHm7Vh!1l>Jx(imNxA3 zA;hl{^^&H~#Sr(l4_(~)pS`RXbW~&%Q>k^PWP=IaPK9Eg44{nUi!* z{g1R3V_2x@7}z9cUVk0*kGTeDktl3>BsN}s{%qf;GTGJ%YaS1casRSYsCom{I_$;u zT?-djcTNiom;=zH=3ip(L0L!&Vo$5yUx#X+1QOCwQy~K;?Ws}6T0fu?F?n|{rS{h; z%Z)*L`^Va_C_C~Wc600-4YAD+^lzXkkjNhl3C6sqWHT1ST)$VDR$^a2zvH57Qc)6} zvcJFUxonkOrX+%@8l-+T8osdZdYgJse{)=r=uC)-7y71p?FR+sMbUVBZ(sU#YQEq9 za_Q$qG$!8_fDv{*EUk3srr$- z?@U`K5S35Y``#lyqGR52eXJZ*i7WdnYBY!iQJbKQ^&_mPE*Oio>|RlErrUjy?f^PGkkf zcblLm{MyCR%vBcOo<4t-9*>>ZjG3n%LQlZxpJCn2tF;7 zt~_1YHO$I6qxT#NKNO|DL7FzA_N6#ORqB(k2q_q?zKcv8q})H%eJUw#3*_9}Yp8ZI zpz+^XY+V!Axk@&nfvF6*8ZYG&wmO5_6B*#upz$$qu_cI1wPXJleUOwg^TAqLDw1<)AiJ!h{L z>Tvo8?W;eK?^~p8U(>c8 z^cYZIEht(m4-ABfZL&_rzKlDZyWCNB6EE%!yyu`8m!@iyEP*ap+nC8bUXc}kna%XH91=LdX&j|sesAmF6NkVB`j0#)gGNhp zRT%9RVv|dGcZIOxseGt@M-7nJtic>MIOGh#cU}g z$$k!^v~@`(9%33LRS+ULTB0WuG0ItYZDM|bH!`7PX0yO9vsTN?ewWa_ zl^bzp?9NHJse(bL2X&--nDZ(t)YA6GVH5v>qlBLvQ;7Zye)48znewk;+l}7vvGiGN zQJRQd{tb*u+=XaP0oDcF{FE5BuKAaR3}bBY=9v_Gh~xH?W6Q+86%PJ0M3rqb6;eCt zH${$aCWre4V!?9s+&6_x=B*v75IpFQR;P-)P5RKR~cui2TuJp)ZIM zq<)htaa10h_o@9g;5D`5vL8U-)nWTy$v)~K54h&Nu<4#(Qx`TPCL^7syjvfT6{ zH%9|!NQU_a(90y$E(QUzCiUZ^G5*|NU`7aV0h)-cF>9f&HSm;M=q$2JPPe_>0Ugtx{ za1WoF7)4A-?EO<&lB3T|%<4lDqG1*{QQ~hRo)R-!>sV0p=*?;XNK)#zRLpM=>b%<} zi7olQly&P`$oP+K3tw}6;U;@WjgQ|(j_}5=Fd=|S#asMy916F{$7Rgv^DZjH=SvCk zT6m}J(+8Z7szmyIZVYwD>M01s2ew`B<(VGt53zV~Q>!khl(CiOlE+c*QQqwz&pfqP zAWe#mt}eIj8MR()1`%S_80(29ZS;bMyW=?J^E)<676?*r?))Wg$q-}*?)ZdIb-qYl zK*m=LAK`Dx_eF+~=*Mt7;{nuJ=4cFL$tlvhD;r!9L@(nHz{=miQfV^N!(7s+qp|pr zwY8ZU{100lCnoOyZKWt|$#B_Sg1LQ9D*&`e6@7UYWHFA<#=mc6K%9qM!GS*N%l$>F^ zxvVmi3O`t-3XR|^Deh=p^&I7MB5|-hHhmQh>Fm3E7>ivjd&#LU#$RCfdpud>QRK#c zTq#L9Lr>+>7IO|rZ_RtS;k9<~+n47Lp^U>XH6@&xU*3D9h#4zR)B)oBK!$R4S8i{8 z_cARWyhWJWVFg%NGMI&GV|GR)iV4P?`Szc?C|oUf0gO* z7Am1Xo15e9`F8sOmB>k-+0TP7+3^Ju?&%MU=?-78!iLQ&;=d2YrjLIcVCc<>xht_%$obAy{VXFXMOq#(eF$P8%&Z-N=>xBzPacjm zwe5lb-m1KWZ-LLk7Kf*X)h7QX8GeR;wGcJy)WjJU_@~y?l=bu~ z{{KkoSv-K4ucT`Gir#hKvk!xQfTl9df}`WM&EAQBWa18K3;`%BH=~h)5CfdpR0yEE z14!%t@_`W2Zx)w<<1hZ6;f2Nj1dU+#fS!=Q<1Z82A2$wbFnsE@O!OpBBlh#R2U*J_QieX=}{R ztD>Oq#HC1KsXtoqdaU6{GtP5RO`U~xZ6@fSZs*$2L<_X`<&*kj<`ZZC_KpkP@f z-#S?>e{v;pl-=bgL=`}QOz$i3^C@ExGNfj&=9Vf(o(j_6(JQBj@*KC7f%MT-eN4Ss z1>`R{*C<&cr*M)dzy`RU*=DzHEqq{b5Kt+7!=r$2$ONb`VJ*A__0?hI%j7}F znj8|OTN?HE4K}2zBF5OPG2zWF`!&?|`bbhrPexJ|7D2U3d6Em(Z`D)yGyp=RbH45~ z0%V*lxH4L*8N`)#*8qrvD#^ntUTJrS2L^T*`QY7%LBuQ>B0nfcpNn)yMWAq>{i+R_ z0Mtkx5S@{dGCX)4(w~Q|s;Z*5`2V%JMP-R3%-npw$w@s>`vEf#nT02mLp9%RZ<1m> zeu>>!2hEP)6+)e!OkV+$gOHUE0C=GbAar5@Z>z^-5WPkg&mL#=~J!2G&!xN#Or&DS;Wu!Uxdu%`ci*x)sz zq?BjO>oMKEcF<4`&dha?Mfvu3pm3DiU8W9cVvfV=Dl2bB5F z=OS=s14>*{0?H37Hvn=UNwDW=<;M6rh*uH)HX2L!KXLzzlh_1$$nh6{`WP(vi_pYg zz|kUrNq;LC@+Uog5Kyj#aUxna71rBsU_#s#FDW?{-QyMjRak&yyZR47H`uys=?ya|BG7H{a{-e(t$%3BL+AhaM zVsV;>aQ_E}Fpe>>XS5)JU9_kn9!9T<26Et)fh8n>lUMnPWpB=H>Hqc(QL}U25VM!a zUKiIi+svzA|6633SIGFgwx39^LdO`uV5`wou`zH*qtdykQ+cbdKRhOb4|p?r#`GGH zQ2j44xbBN-TWmVkXPDo5qHI_gi1`E-l;^1M?`;j2X5lT(_DX|WeG+|y&x6Lt`S^;p z#v2ZXwv)CEbdVBF;jOK;yQW5EHTuasiDjH@SsQVkVq9_4*jOd z4-Tx}KyS(*!Y&s&E#HanS0dp-|C?)u!?nZyU#?l5J~O#)NAO!t8sdQ(?Y>O0EfC{E zd|1jFI@zdEPd9}QP$;A#otK=vA~^b8b#&X)Rll%vw`D3~<^k3ue4GVwE@<%y-M+U? zefs%0;gR7UrC@p3@AvtXnsQu?!8rQxu}Mrt98^)uF4pI8yWks1X){(~`e*OgyXzcx zQEDBOSc0b9Tm+6>^%sT5qhEgshQU*O)R_1k&fZv}%GKr~FR$1un?p^GTbinMW;%5N zG!iO{7HHGUy1G`F{V?-Cgk!RQ`|s=mBG3=yjIMA5+*iqH_jT~p&tmKee-p;9)atzJ zfK@Oxq#2KLl;wgbW6~n0j(5k4BiF{{yT&zsV19+WQG=BGK0kdxSBA$gY9Va@%+&Hs zt8|FlJ=YB#$S1}8Y`+l?cOT-?&py7XT6n|t7KBo3o{8H}4Y36=lwZe{^H`sCd^HQc zAc+9Pf1bK_nr9)0HZvw>D!<^r03b~wIx5M9)EJou?;4*nJW2TI(%!l$ zU}qTnkzWgWpBbg6C)AY?uVMCnOC$ct%>+Fmv(#r7QKBR@j)NeOhz#7JtL$lDlGHH) zuPQ#XN%z&5G-BA1@vEeZ8>GsHX)YhS z9i9{)f;1H%4CPPfOO=Z=WRgH6s;?~+H>RpRf9*$_@Q8PJR27EQuOr6%Q=XdhRYS$S zXfHvFZ5*@5M+o|rvmpx=skV0ONg>t$($HL?MV=^FIFkBn5BDDFQds{P{#uLSs>B=l zG&BI%{pcHj9AD%!$6t3gHk7Lz^%j_p!V0q$xxH|GqKbh!M|-~^Q9ctkHLTla-b;W; zKlgjhT?{VmqRgvv1c zbqyScMNc6{r?592{xtYK?9q}a1eRNCbH*9Ywx|(MG%csZ<%`$ zBRXynDB!>7EB--NHHwb|qTix5-IGZ2p>wA3{k_>h-$Z6m!&3q#s+1Hl=E(zPk8JMq z3zMU^`L^?J{QM-FrRe`!(D)0sKTEt2liOn|o;XyfHt*S@Z4%=6}Wj2QQ&66J1;YU4oPsVBKFN7L$vd-ILH##n9V@ zcA3BsJh}I#CBzs?pU7h9`l^1PgB(QCsZ*h(NJa6FY-cuGpqcb7uA(SFaC^mnx2MHe zTz^H@+%44iRrOK1{s_qLIdf^>glXfXf)7ZL3v{=V zj`Gw3RF)88m#mij3=eGh?VHGe>t}%`%+=WZlnh5Irq6x~M!K2jFM_g<$$oJqfuN>~ ztLc6Jnsnbd$7^x)(Kzw5FM@oe%afgCR;SRO((sgG+xw>Y2TcijCccQrfl)o9T-Fxj zyC+|Gnshbg{1ZkpSA^+(>tA76=XALDN_YAA=7^B;GX^-G<4+YFRd?x8|Bbj9=(za) zXW*CE3~&4Dhu)%!B1Q~AqQg@p2OyoZPK>!ox5Q%P(*XRsB-@T{z--1Kad z91b2NijBwSy*Sdt-CFP(87q1E<=r6FcV~%DPrp)stKwld<{uLD)8!4xpWCAfzbxrm zJogt+40ql0frn2~-+t)OVmj2KaRn#Mw6y6`zzi-!>R~=j34K7^l(taQ7p{E}4e_Lw z75~HN6o-3AZIcDv*I0@&CUX3&Dv5B=cb!bQhLG|{KvJ5)S%U5@rZlxl_i#qiBdd)C z2=2*1sWtG|!je4DE=FH8>Y_`^$FjRg&TQ-p1yneVyqi^wyiqWj?H2m|PvZ+9&Z*7u4b0rRsWp{95?b&U;6%>o&fXWY_yjN}Q%Qp1UUt&U)JS^^ z{-3j;(_MN74E@G#`N~Hu$r5C<09&Uc%=`gHUws?aZ~q zJ&|NyZ6f98@T*6u=QBUT)sp0&QK+Y*CvJz^cg#BZh@6Gn{J}g@{xfZ9b?ZF?r?Euv4sE{9afp51b(Fz{ zek_RPeNg&mUSHFDEto>p?TTPCa~M^yX5HrRucUfEbRXAy{atXP4I^;~k$Q(Ep^NhY z#8Q^y%DeHrguAU_HZACd)6)d4)7Rq}pl$O!PRsYqp*lOTXnSvpi?0aFfUECx3)LEr zm_@@=PIvd-ScXEO`gwwafOe1(vb4x&fl(;uYA=lsJ7zi5go%XbRC7#ZWo`YT-LBE0W)hC(wpL3~Avc8E?6?ax zRooiP5h{0~vNM+68$9YHEU%`pb?{{VFTVI_p(yo1p`d4SuFK8|J#)fwl6r36%ka^I zcKSlyoB!>^RC!oy7UqT`p6LyAtKRK!AVSsvcKZm@yI9RF(m40NP|SFo!095QT#9quj|H=Q6A9X- zZvm8$yy6TnPPISpuzo7#G+@jcB;tcKCd<_3(>Jo);qafo-Dz=26OoO~u^{jX6dIpw z*g47MrYs>Nj5uGf%|vDZ+G=u0+ss9~_O!%G)_0&YMNnq%p!Mmqf6AnQe^*%4t*a2q zF9B;Y1j zhtj+Bd$|LPtd0-8Qs{DbFU|$ZtSOkg2ucDFkie`jZBH>DZ?Lg%!y;l zU_6nAeW1&g3^Cy$46IjZ;rgRHBRv!d8Uw&QHDN-84Udcap6mlUMDdE<&Q4t{y0{rw zZd`+rl{9Vd*LTZz)xs*?d!|yffTN?f>FVL7oxK(w`rTlwuR;X2V~G8$CP1tU7{Ib- z4PXmAA%ZRPVJtQD-!ALx1T)0;ol{;($vwe^MM0ym!$io4BTZ-z)_#Q=!j2QhS+y~gibE-4V2A`pDiwV273j~6F!_Ts6I$cXdYLVIsEZe_fiD2RSD^%0BT`-YIX+UfJ#kI|Rz2g{ zK+}BovsX45oL`mCI$V-S%*U5ln2pj?CacEB?~ow@kL5PHP6|Vu1Q#tHb0>t;Zdap^ ziq?!{z-lQ!6Ex~HS!0m-ooax+C#tz%&cyaicWa8hK!fs%GgR7s-=G4jDH95-;d>wNluSOSN7{!c0Ffzo?tvIy_YonL zZQ@g0o$X5~)U854Sq2!uH30uH6ZcXXkY)}h`{s5)pTc#rJxYWm>KTkHG5xjfMAm}6 zS2cRgrCc)Sv{)bkZZZxWOCZQ;*FE|JP{cPLf=+t~GJb6Rn7|#}v-?i4r{1tYpHe;| z(JqJ`^XRKvZGD!3#QD3d;)s*(+|hW0?_cLGZ^~LUC+c6{^Q&c%u<~V7g(F(RI8bjv z>Q|G-C)Jt_>6K%7v+tnW?g~lWPAz>^ZLfdVgb|-Z-JN0`F!XZ-gDs@A>YD}5K}U<`ZU?T z*>ab4lEXJ2~p%my zu0!}uB4N=Rs+dfNIwU75d9f;3W$q_}`W=?dDpbW#6=RzXlr}uQyd;Jemh1}3btkNX z4SB5nSyYz>`#{}KO2-KNRXR9``K-3 zl2wEd4`^nKCy?(uVCx?DjX~#d==bpH`7^6k>=q)lv%iuSX_El{G_m+E$94hbr}0q5 zG^ElYpT*~mjSaI$$?E6h^>dHfo&uST+%&*_ROvT-<%DGzFH^tdM1H6_F5kzpg-;hZ z#Y-^1j^SP5npX`K5+ZER+V@__8+nYG%Rh`EHPY8Bg%wiQ{b3(Vl{XP-oICpw9iS8N zapRiA&)3ZbEZd1nA9@3(AGa*1j%uHVtJOHA;!j3i!)vrU~DuB|Z zpoNN(`#N$|{8ceD5_`))lFbGmq`x-hIzZD6Enyox{QbPtsR>38WC~E!lL3l6(%ak; zVX~Wlz@=ru!|7soF=>Ew95HzP^j=T$`H*JlJ#DN@O-O`wyvt`ZYgzd0ZA$sG=GN9o zqKU~JVjKlDv~F~6{q3ATnfhC}yYlCNNy(N0!9f+wjLrKJOw0G}w@l$mzf|hoWqCc{ zx_@uJ&_C-gQc=1WEeM4tPB zHFpCgh#-v7&PG!A%>7)Va7ubm@4S;dyveyfwREL~jmb2b4@Wtz&dwSxF&i}{e*d#O zKInTh&`4iH#;^FJkkwB4?Lj1h_!irae1L0~EQ`}hJ}*f`F{I^Sz?xA>Rm{~)+`A&i zVUJUqcvXRp&1=KaUc_&t+5oI%q@d^{WEjz$^G)i-v-!ldW2tW|dA4dq&;56qAMLZK z7G)3B1bb+w+G7{a_rbKy*Ps4i{xj5(o4S=~gT6>+0MZsN*H4c&C#&pk7eF5_0M{YX zPc4rmcp6X*e$pk9O$pY!dp}ARi_&fRMPGA!GOcixeBn~A3b}bVq$bJ1LpnpryE~b>Sg|9rVLHe6Ysrd1G0q!Ef!9O zwl~%U^Hpw#1Tq{*Cf`6_2Fi2PX!ibVf3p+S(OcLp(QlC}9clOcn6DM?c;m)b=8vKU z=0WU2vgpxa#st!^5vFUiMj1ADc8WS;vPpZ&UuKU}gr9b^OKOe(RwBn5#@^RfZs#u(6(`j@})a)%g{lk^b_1?a9?LuComDyXoHs?c__BOYg z6}lL?p&qW=?W%$SS82df8z_N@QW6%z{t5&lK^r#CY!eHLC!E%r6l9>+P!OFXdRK?{44TR82zK+1w=QDN)W#Gq1De zSOF}-#{-?O+juyLD}VN6!Qr9BxWz@#Q2NqX{qeQcetg-+NDTL9vdWZem@mIcNsGrW zqdq4OrI&bKCXB(cl5rOGeN%RaBw$H7RO_^0-Rqiq&Kuls%%Ls9lwPq2UhA_e2CnG} zu031ETXd|IU&>yNElj_RfBG#lOs-^<<_DKTyP4w8G$#j5$>;-bl!4Yy1pn?c7RYl7 zSo6DfMMa4GGZh(+8ELn>J=fc8I25*mOf{k7jMC(5_D4s#-ZP!NO&`cmp1x1&PpUUG zAJ(Y|T$d-~d^8&B!CFkZo+Z(^LyhQc86j@1E;H-cCW$jedd>Xc6}u|>JIXM(bh|1* zIy-tL+S93lA$}ah@Cn)lb+}x+vr8!pL~a;16+ka`6tn;c#J$G=k)%~+$0YP-6N=RW zk5@DcZT1C2nA?#Xc1zICyxD#x-ZO?v`Tg$<&ZQX)o=Tz{sqRI4@KObZLNSxfs zFtHZo%R3{?ta5xYy@IT=Sn++Q*7mx2?kll&qo2U-&I#?(Q5ao)q{a-@i0B~3ZS4d? zw)u9iR*Gzbn18sUUfpwrVMPt7om4AW)w~e4d<7kJT8$(!zhUP0QlH5#<6$7VZ*FJp z>~FY2NL4TO-KQ=5k7 z<#xVFc@MEq2WE-NOQh~~Ok2qq5ya2pVF@jFttMXhqrh+HiyxsfC_9?-sjlsM->T2ixz&KYRtfhFnk3%TRTHb94Jf zvEu%}1xAmri9v*gA7MP~h9<_uBWxsEbsLgyr8?Qp-O?oAVG3<;>OF+kjB6P7+D}`U zZ9UDUgBWh_#11JVY+2(4o6Pt2a!T=2#>kq*q9@3ZZdaiuvR-Ie8I1_{?9K3&v#^CD z*J7}0&icAji*v9@MuIu@2#;@KqBU`8MUb76W48vr7gLXxe}+F? zo)v6Dd5^nV?HXBy@l6olr|%Yw!A2&l-h?pYFAWvHf9;tP(1LxrciA|UO;3Tjtp-&E zH3E~J8 zdT1P=MRAmw=Yjb3wcf{ohnytY4&iw}6<{2k7*G?c)>=swHD%BO+vn3~ zr+-xso0F7eU}kj4!s7{%I7P+ggBhOz?gm5>7s~U|mdIdLBL`{rYh%SOAL1GRn)!cC)jzM649fBkK@#)kz`4tWG)Qfow6 zepnJHePn+2K%DnO0{K_+`#IN5?0%|#x`^fp;Yu0N?A75jK+KxxkEyPg{XKni`BsCC zNL}{x$Lp2-9z^njh(pr#V;ld5%DYV7{*LV-AC{<9NTng~dUdsN>YlTWcaiYW%_*?T zL%HHzzB`29UoghmfIAGY38PO#f?=uaPGePXRaM7zdkcJRFdgDrchA1g@dYEF3pJtE z4^&33#QoMLp=W0u985(GzQih+$(c7P^KVozmmDh{y`vcLMmS0!61-*oWuJdd!Q=sp6$FM?B zbqAg%oP%#GCNU3XEx!i4BvOqEmTU?aexo|G^FH=a@}wz$(8G|tkt96Cf7Fkd#d5NY zd+s|HQB`#lLsl=J?Swk!DvaGTxWPZ7#Y>kU+~^ON%X~|b`eGcA6MoZG^zr_r1Ce21 zFdOae`#&u}gN&Y8%U+~F9h7vsgN|LNl!wOEM|b1Y&gXUT;=uLKi;Qy zRtf)d_w*NgmhND~z3xaAoTzXamMyQ!4--_Lu{}los}M+QIx~5%W^Ox%6s=xVzg2s8 zqb^AN+MxzvJeTXwTj~=sh&`yGU+WDbY@uVurc01r1_&7XnEp68H88d*AqLy2C8m{{ z!q1FJX${q^3J&!Fyv6L)XMfc*AVoGYVrRu89Xlgv);oLj%b>9ev_N2LimnVE?-v}M zl8?x7jLzGC9~G*a{zX!^x(B4Oqc+Oa(KBt3kNk)Xl-nmPog*!|BHT_p5VL8xd z9HymOs)cKG6Q*ZlL&mQ|yzb^h6Rf<%+LxCA`}1yu@hvr$)H1!SDqvmM?6q%$^d)aP z(O$<~7(PB~*Y@$xH$ShwHn<_@TD|^fs~smIuUgwe#}tEN z-?Fc@6`wZKe~R7aB~Zec6(iI}Am5tE6!7`C5|i?w`{)8t11hyMJznsY4om?acLaJ( z68-~{CxZc*&8pKw0CW{0fjT5sg-=y|%Y2j>X2IhDk!$Kkl_YNkzO~}lw|GAY}?@CewHSJ&VMfXl4v-uCd>>zJ03NO__+RB_)zbwx? zf1>8r#uf9GcZlr;^B(?DtQm#;^w?Kq*RfNpC)>>fa}2gOl$!TzcodxOCE1B%nOr)k`X3tt`PNl=v_9 zFg09@WoD_)`$2+^_q|GP!h<1uHcdsL@T31kcfB4e9SOh4c=f9H-4Mqx~j|-ylZ;35sqn(iNZto(MNu&BA z*TqlF_{A-Vwsy#@*t|<2wqH$7+cRV(xM?bIV)tdKQ*!vL8q#y)0C?ht{CNsx?lbpa z>u#ty5@Zta#P=Po-+%x4*r0oF5MW_`NvRKy)~&XR&2SoDxb;gGC2$&^;dQn?&FOJ* z7$L}ep5%^9MYe@wl6Ra+-lMD9N^x$;L7+EbKK#GF`d2kpwHSd%-^-~$c?gU-y5v5o zQ@@O!jK2mh^KJg5A(JdnxYN7>kCz5@-^wYC9!4~^Jlov`l?qaE{V+N90%E(Pn%y(@ z9{;O&(MqHI(0`N*6f#2@KcRrHMr7Tb#!|l$RY6_#Kz(Pia9;^)ZqwNpEgTq}{#Xs8 zDQJy{L+QUw#A*pUka*g;g>O&BPSn&l z7$8}0HmS;(A52_&Zr`6S9B71jrJ2L9?bWX=js$I2N&#Wzu3iijgO-CV7On1t@Q`vZ zj2z&*bT2D9ZGPW66S#Pu;W1e+aULp}-*5jL!5IsLpvS1;0Mg-Q=|2C%qVj}G5vDdz z#7@Gb4(eZ5?`E@2R+YSUnB=Ppbk)aUF!7gavZM(!Bf9qx^(^bJRS_imKG)~5I#LjM zMo6Ftu}$~tz-tnmz;PpcckAY%d0mW$ebqJQ4IP}Iww%q|SEHZMaTDH@%4jjDr4xVX zmz3>jSuV;)s2u(&@da>D!e}(s+$(sE9Z^D91^X+~yl`Zy$QHk>;w9W-)#=Q^jztON z&VuKhil7saLIyQVM!A76{qK-OW3D6~sKa8``@Vk-x@peb3O_$$qlM5U4JZ@3@?-~@ z9bZ9$4Fb>d15QTSW-<#V8HGbDRag?Ne#3EgGR({r zWjBtfoEYCy!epT=y71(W2?h3A{~uTH9?$gu|NmDCC21Wj$1yU8kZ6ca&MAjV4mnmv zXwFO+%CQibx)_xt_%U4DPmf3@xLd^{hA`~7yi zT?yE1hDG^y%dTRn0V&uIFlN^W<|~QmgApMqc2{h)sh8;r!3>(weh`sd1# z4|?A5_4*Gq)I2O?6HyjEySxx@h36KB%L{*(eb!1D=uN<$Ju<6(x7Ta)I-Sc?7J0W~ z!*}f6Y@pT_3g7RVnW<#+!NEFT*DubVI#-+cVdmA8{q=9`kptgmUfqBX-SWJn_Nk8T z-zT)^pc>9@hWb6$-Ev#VIr|X3!SC>NMiWJz0g(t<<{Hn}gMnqHr=#>!unY{x zZ6&eKn7i(87~kY6D^K=3o0m^hUv`pLqaojOKd*48XJ0K|Ef>u`lu1+Fz0 zn!o*2l@uG!2x^?o(cdom@?)&o>dN%5FG?8GogRI7bjVrhWM;#;>ZzLDD$1){~}x*K_X`gx4snjlqykb{Vb0LHw5?+{^N&3(gev%gF^FdVDiw8hJbC_}WHYiu2KBq{^{u#p4q+|a#F4q_ zz_rsI{*_GMll&)5nnLbYUoJdZC2hU@sM`8`dBToqZmo?=Q`JX%?~(V4W$ND#a+ja1 z(kO9#<}g?0VzAzEc>sB7qCaNft7)B|5^A6cW{*ocFs?<-?w zaww(h(PtnN376dJQ}|5SIDy~a2PdD{xPO$N&OvJ+o}h2htT1O>etKbHxdWU&kfV+! zoLm~gf;o2M<6$!;^Ps{3#@m8_isI*VqZ)rs^ue==Kb9Y(Kzl2;b-C#`#^j=<)TqSj z58De@Z%{rRYtaNID+bCp9E_g1J%dyu0uw zQO;f)*}o|Iba%}joNm5P;&zZ%Fj5U-8Fa-3{`eF;}>1 ziMM_#xN&LJhKyPTBuQ4*EW0Y-0jghl{UrfPPoru}Dsl64gYj$E9ny$xZEmZ|h&fKw zktWAj)1yXVx#=OYD6aa|`TNZwPZOfFwW)xC%{+q%jU%C29uvKnmcvDsy#fQ7Dt?q! zTb+wY3AC1sfI~mx>fDP?R2)JKxyW9@x%R<;6q~!}Q$}LRoT)zXMHS}|rZ!6)T_Z1I z?FYSW#6(E7>0b5Qk$&-Pq1x(qc=bwvi_A#ldxMzebz*+MRr3WGS(U69$8k9uyBRCh zEdt>PC)n7tBeiCXEK$mCt1M;o8|-K-1TZ#DN4+IPFA&W6vy560LK z-~MfOORwdhcc{Ib-pesoT>JEUuf6y&+wu3bVf8gk*L?>ir{!f{M~au3P_uJg>yN8A zd--T!yPCo}>URitxZH*`Gmye~9r;vnZzMFaalm$x8NwN6(J!&Sl-iPH#=+yzvxLDWuAXxB;rzg_2=+jYj{t|h#L#RYfQCR*yI%w`cyPj#Kz|2j=V}S7F z-afhnS;&{-F@{VA|6LGh4<7;9JltN8vy>_PFNNk9q2-Udz$8(aT38%m(JfwOi)UO~ zy)IGZQD=>3NFV@n{C$H-?G=yZWwox>-DR68IDr7Wow_JV;`_Zb<3$*S#9w9h1YAQ7 z&m2p;QpjOqfpQDWj#MtXesjm@+C%e(muE&43NpZ@-G=7QcZzB;^umZmzSZE1>nAJDlQZQ#DA7+eGOUF0i;+;WGg7WKD>H)EuBeky$9EW^}w$;Ja zDjWA3r!niLWNrk^gJTMAC$Tl0j@?n+XSg#l^K3frAi_QjkZ1?QthslL& zr}2er#e~owB?oh)*L)n{r{9gTiK(S_NUxna(Jn514gcZhaSrH zj$mk*W{J0{JEWn$@d!mvgf7dY15tF=m-#aBWng2*LBNmjjxNi8*tguL>XGWk;FqDh zlW+&i#9$!j^{B4^I+1md_!nl({H+CgFMsSN*{R*p&$K1lj>lpGDbKZS21L4yd zsrL-C7n9n|f|>6wGR?xUM)X{9J@jLu?6Jp1CN@nM=trT+)h;sIA2JvrnPj5{_+k2J zMT?36eR%w?%r@Mu1KI!ht=J8a(G_W3S9r|p7O)M8s&A-VFbsg8H!c}7Rd^jaj-VUf zZ1PN3Z=8Dc=mS3bpPh-3_AzMA4XYEze~e{=0lPLw&ZuIN@6o%6YmkQwnM3p|c|USt z_Bq$j)k}geVq}B)DcYGowjz=`q{}NJm#_-MXCKkXFl+g6>7`gV#3T1mEz?r%{6_by zz+N*y_ay?z-fSA1fUXZXdzuAvPQjgiEqA8x?z)6wouSL?n5}bkdr`SNz4TRGZ#ewg z6`FB4a(U>x;j4|!>0e%ZVTF$8)TC2eF+B*;AjN2n{2$&0#8j6nPFZ+krgdu z_00!+=lxav!(J{LT|7udzG)F#X_RfVzpqW8)tT%sz!m5E)1V?@Z|#nsgoTmC)s8?nIaGmqJtg3n+Y^EZ_+lr6925&=cfXrTS5 zZ#jab3@z|-M^aK*YL(QFEjVHG+)FqPHTv^b;n&vqh;ZW6Kx=b2u{5zY<0`s{pQugQK&suuMXqYqYF&7G}> zuHIQ7CY}W{qe@`yau}JMGMdM*IgXtp#Tql8djZ+eOI6FMvoYNpVswR{yrS||g*WY* ztnt~0G)B!hSh0OaTdAdUgL3=yaDP^Pk9DE6OqhvXtr;6u4odL&86T0V8*-rNSdk;7 zWq!BzM%!F;PS36jX+gcmd0s`=V&P%xhuppaFLOTHz$x2q`+4|SyPQpKs44v-&U_(h ztgi9Nb+wi~?I5;=TMmC6X9j{49f{|&7CAA|k&K+o>)fZWLBJCz6bX{^T!Iubr1F=5 zMlHo``W+{R!f|Wai*{qa;-d2fTR^uA_62G}6KBo#&!K)VE+x?VivzIy%15ZnE9ocV|_zUe)!p<&-TCWr&Jco3}P&=VBm z>~b5Q$dHtj+3oVmH^b7l7fI>g;}SZK*DhbJIK_W$4y6s!mveo4@2Jr1rkuH44NO$3!o8WNtbCy zV9-2(LPP|4d*9wGk{Uz-wyoyzaj}cYX5F)i+$14o2c`Q<3e9LFd#S)3>dmajdS5@c zP`w~Z6g3sueGBmFw;L*f4uj3Q&SoR+n5{Idy@a)hwRoi`NoaqsCu!l0%(=Iy`$&@Q z#C*nptk$u_#l1XY7>}<57gyh>dvd@!`q?SVf2@z10GT>#YuZP@>rGj_b`5!v*-_=c zXl!KcEs+XD(sDfusb;VT-tnWOI#4QMu)c*^$I2XZxiJ}SvNG%dC@#yxKoR>mZvOyg ziZ`~{Ho_QvR$ za5N0cjY%EGSbTs6l7cxAumhC8_JWJJONdmbe%Nr9LdwM|n#wzZIER9qH^-?UEcX8W z=Xd)TPH{)+uEIW4tk2jjY(X;I-sZIFcho-;j& zu}4NgA$1C7_t3tvj&s?+&xx{@iHpFt5pEeFzv)J8ewq|((yg2rchHbS#}PrWXJJ=%zJ>Ov8T= zf9DzqApbZGFL5fKIbk!`=3~aJqt{WfuerLhA11yYsy(K)G0-}8>9B3z2|KmD^v*c% zmG>^k^AV|cJqG+=BZfwr%PWlfL7J_XiAd@y50G6~{@T2b*c$y5Q@)`BB3CoWu>>Lm z6Xc-OC-+hz)x3Y=TbAt7%H|gTVz*8JQG!1r?F!rG+ZOSg|w;b}hz@DXbuSOyM#16XBwzc!r2r zDx5wjrLv~ZK0e)bV$m1qqB$s!tKmU=X9;yHL?{_f`^@= z>)x9-BD}qqu1&(hiZ}@`Y<3n#r%mJHJ&bI`$1x6(FjabbdSg2HI@}?j<+RL^$yHzWd|ubqq)rK*ICoFCSMIv? zmEsfx)rWh=4lG~C9`C2jr!#&7YdNWvorW|dud4VihI z!GV;&5|HxOcnPeS?;AWgWwtLwx8MoHqfovHLu46Y_*pm+-rfIzp?J@sBe!98PZt|< zmPE3(nDEaBviU!E7qNCj?^n*swFZ_5wZ{q8ihHGGkGhKH83}6nECnw*nV>B>$WI=A z%xwsJbnC=by-Sx*KkSjbb?lN{BzKsbAajl|3oBXQ;n3o#Sbe(^{JT0v$@{u>S=!8& zV9&C%o+7{L>hiPcb#*Z0B5J-9VuvXn0w%~@!o{$${5!xd4N}LV7;r_?yu55a3`Z5n z?K^k2j)&)QJi@v^f6HV42yzb{eFq46J_#P7R9K{oZSw~DP?8Wp;;1HY9a!aJA(%g# zJXeTuYLs8!aw!&}`y$Wn1F@y7T~C6H?cbpNk8;5P73QFXHfP@HX)|Lak)mm-8_pMU zTpD3LO^C3hB$ui-@r=L|CpZ-7QYR;#0kA}3_Zj&{ku7#JG%zP$e!INPlwHMKnaaAP zWDL-)k8|wfBC&QGgD3voJzkckU_3sH1GHC|DxC{Jk~0{NSiE@Eqprc(yyO!%DKAza ze5nZ(AY^eQfwB zrm$_s|C|wjpuqM$TvZTx343Ux(y9qAJ(wdJ3d-aM5d_L$@A)ek|@1&{W~W2g_%&B zbDb>3FoQv$V{u99bd^@5qtYbUG+3Jw$cU;94R{OvT;IO_%M1)uGbd$HJ7P@eKVe<- zGBPAN3h84cv2wZl8^$3D#>__~q>+sdhI|?<+3ToeI0&)g&y%#yA&4J)x8&t?XiB$E zj`AoGRfu9mpk1JLzqM^rLKhViY<0x>AL2BwG>-~(E-W6xaIF1YvwL)8bVqJ%03Pqm zV8ERcs&&YWB%}XjVaazsv~S8C=kp|Fzg|+<8_obN(0DO5t!{X?<~-mhxEEfVfZu8} z&gaxP>v;F|FR$eu@fM6V2-0GtegguXA>E@CW#@@0fAE;P>2ph^pzS>|y!+xJ?qbYf zExiF%{C&tu{8;P(OlS)vq`tZ3GzQx)cSjj*e9R&D_QX`8G%Jnj-~#k9KS0@E@+I9A zZ==HlC&W}Jnk z)AP+y{HUVX=C)RIF4$U|eiN8m1)Q}8mcK8>5>HLsRi|HQSiEPsE(r;N$H!1iE<8jF zu4^*iiVOuS?V(NVF#{0LV>PuwoW#}~$ph=x=YqnYw zw?Z-eh$Z!pI;#E~g=D?QDc83!Py!V_3l}7#+|Hxzu94hwfyaN#x`gmZN38qj^gInc zvalG!S6eHp$@GdbN|Y@H7epmtGQ*==&f7wh3L=7%;oB;<5x|5G-nP!?f6?92{L)#E zy{D%3ls{4+ST4hzXV}Xxfs?AKG@{Cbl_=C7+5Svfx!AtE-T704KI_xYwWGWDYWiLn4q_ztm^Pa$A0MPy!5I)_4U<6qIM zq5*qnOVmSgl(e&XA6O)w_7lNmwGpunpoD|DdXg2R!#v=MkB9^jE$meQczFE7FRw5@ zHCH+d@w}!qq;ahCj7hI zEI0-@9zH&0y^4);T3oP-WiPjby}F(WM!5I`hS2U z=qAkk1Bgtc{3{!J;K)>;VIVsfY`V~ZdB%it`}@@8&#Zu)bKtGlc?mJ1@SqP+c$$*H zht&AeYG7bZ3oOfB_5#_rV03<9$o5U{cqLW3d6fMp%!Y2mGqj$hwfM^_=g*&orhOr0 zOA08mIWFGua9d41@Cx4=0ZAySX^et;gMaTU>8hAo<(Lr*-Ia+Bjm!MTXEgZ+E|^O~Fdv}ia=~7h@5CvA^8>tlMr7->Hty2MRCF}YpY_{BJN)mR zWV{0J%I^>34`NGWf&Xnc$Nav1d{ zG_-$6wO`v;<2$2hI@0ddN_VLVumb)O7|0KG5J~OTMb^tvT;=iEhc$}K^lL9kCtsGB zJTU>yd}JC1^ULSKnoaMoJ;mXuzi_xV!i7a}J^VLDCsv@e6o^3q!PSQ%YeF5=)d5oo z=r2$ey37W?t%*kE+WPsRAdb8H&Zn2}qrdcd>0a?u#W*j=Kk=&@h~Y}8t^HY#%i7=a z?mW|cJ2UOhQZRK$oEY*Z>Ff8;^YrPTjkvuA?|P!DJcHJU-hS;q=I>E8^Hft2Y@kmEBk<%nu+jwIgu)>?C#wGHcJrD2Y1^W0%X|Ne8-F%xV) zZQ!Wg`FM}JCiiZ|tOpzH2fnUAk*5(o=+vy{o#tKoRXa{?8}0eQI?|szXRxlqEqB~( zucg}Q3l+Vkwa+>yByd#f6TIyEi0ar?tHr6%?LB^fo_h|??lpQ{{SEDvuB6e1B12ZHo z#Thc*r;X>0BeT_us-Dt`HhmLZR506nxBFF<-?s|X*213({Dnnq*>>oT+lT8#+@^Lx z)+P7l37g}#xV=`@rrNl@#RK;yUn7asVU8UhFOJ=_s;+7|M`wythu+Mv%^A1)wHuG* zEaRZV=TSWV&ZKH#%Z*1_jMV>NPCMNpOPDUrDF?m(%QAYYZ?}GGAN`tQM^vMP^Q?p5 zUHU;Rs2`a-D)|ryx1h;sKhm&EnBwbGVw<|VcQl!@t$)`TA5MC8OfWyqJ^bLeg|4Nw z=Uuh;=l(2hTMYh@{3B=|N)FW?+wfTNfPuPAXY`|?C+Y!ftwma?r}x+LR-K}*;&Qu} z(r{VbG>ElD_0AD(YRL_?q&TQn$wME3*rKlG*ybbG%e=I#x7et>{M2VLu}x#-`5|}J zh3hcPR&7D7Q{Z-bT$srO{^%Z+oaOgb)nPT^4(ye$=ly_oec;9Z$ z9oQIF+14+0C|0jkY76#09G!e*_nhm-d+P!Fv!PK4Ya@zL;igeKxw zWn2!Ur22ZC*Rg~yg6u$;ET8kh5e5%19X|+J-h){@v4r&B1d$>-X~p5^v-#9BcArS` zlP?5O2upbaN8;vX2Fi91(?DiXU;aJXk#Cs0g}xBhL7!%9GTW^0(57dBOJv)AIpG8$ z%O4|1vv95^;`b95v~h=m`W2G{qQIOmF`b&yZEGFXF+plFzkdCCl^5LxEn!u&gfC#$ zJ4#IU(#!C>0rWyt$Fx0wsag*0F0SBfdN}C24^61x&NY4cq_>yvoH60IC+yQ9p~f`# z@&_&7q>~a%i*HD8asFf6EhGA9OK`9V`b1SbZHFYJ^5qY4YU7VmyzTmx-By)j<;?X4 zcaA;poVLaK3rF3$T6#lhMXoe=(LUa4WI*~3&&?%i+PjB4_4AwMHaC8(23FbSzh}r5 zTWlY{A2|igYeA#PBBevtedVX=38!y%x{3{XW9p0#w{yOYY(LOvl6S}QWB6WPZ;d_i zd}2*U#g)bPO1$9J*(YxFcXsnt#_tRe2zQahDlr(e<0$V+P7`9r6n|U>`T*@m8Y3qsN7izKlN5`CC;^{S*W5YaG*jtdk!`2%P~Een3~lRG zsU?7H8x%JJ2Z#B?vBXs6fmquJtN!CA)bp)WU{{OA{@x3AXSrOo3MR8gHJHmGQ;1T_ z{2(2mX}pJ0TW_D0u^zaG61p7614?bjPe0Q-la>&s{zLmz-N4E41jiQ~$H)G8k$nE_ z($}8OW6H@bMJb?xCR4aqW6e# zROASF@-bI#($Gy}s~rt3fsl3f&RJ6IsKW3+zi$7KXjpi(Es=38FsKFHkq~m~wY)Y% z|D?)6-O>ARX?!0lvQjajTAD!Is=PQ-<$w2XG>U$Lyi^{an7M~_ zB@a453f}zjh1@$46O04;WAjbsxAPa7#O-hGpkurkFb92ok?I_FLi(S^*XY_&1=7gH z=Bzx~9J|B|d{^VP>-?-`eH~WY%U%w>X%xoNe%^?+(iXMu_Na#70*Pci3pX2?JtFCil-BSspXfFB*;Go+-ifCH$V4n-t_dSL5(#x!_ z5PQJr$}`_-McmLMUxrp3n1xprI72JAW2(!_YHB*m=3d3HcfjXDSIrtF1xkz8M1n3o zf_>>WJWYr9IzyAQo-=eUH{QQN(NhOE?ypIoNB>;->3%D043~ijvX@}h6qKB3=$-Ng z*Ov@U4Q7SN(2Ct44Cq+7@#$fVQH8_hU>=dPDfargo&pczM62dNH1P1Nf4J6El>@LB zZ?^nNW}xnSoj*V?qDMG=uOQjNd%Zukiv9Tgt!%I08zKL8r^gr>10f}1!OTaIz}N(6 zDyPPCjPmSXa_g@%>Rd{!FJEjJ7W_)~3~*rmT-l;}%2CXzbVuyq zQaj5S1&u*i?`VXRj6xtWt?ae5uTR`rz9=)xjr-gJFprT*U&tFXlOf$Be^VEn-;iQ4 zv89Y*RVL`ItbGXO)aGC$P+_P%^P#?qrni z`1Ot^Kc=?8)uOFyk1@M)sgkHDMhN{i@&v_!;-hqG;P?izICg?8R|Df*H7GCSa$c#w zGPmz*$PG8uj&F6ImXU*+P#mQ9i0jfZ-8KAfS>H zggXLq8b2GkQ$t}UxZdo`P}8p5u7t^pr<$}ilFy{JBmk89-*7OIjEg5DZc4ei!yJJPnxwxlbWC zx2O$+I~%2BIUYeb;N#9ANPc={?q~VidEa)zcFi_AW#4-Di9@{xQ0`ZR2OJ*9dPV#kHT4<)RDL|@3ku!v_FIKBXpiE1KV|x z`3#8(mA4P+-Z(kGx2(5O^WJ_j3p)-@ye0LNJR99S!>*yDV?7#cJT>qsm5ZMCZRFdV zdg(vOfj0GAueTPiD=!YJh&U~tZ+`WDHrs6l&rT8BH(ykicja5!#QjygwKC+NirnMH zr9;z3;8^g*%M0@+i<(uRs>V+n{Q_XLcs(-1&=~m?V18)kiPZ+Pa2ZRz@R0bMeXEBr zP*iQRZ{_!L@L8{;URq-=t5Le;_c_JW1NbrI(*nJ^G;%t~B#j_5()+|y@AZSQ%64&9 zX||c2u0wA;wZ$Oqd|e6T&k-^_c@4yYbS+ByXZycFAFs@YW=vP!fkI;KJKl7E$?noT z7{Fa2$6ga5z2wB7SY?=DTNW*`y%s&p7}(NAPN&oNCOk)&s=G%+WL3nzY)gc>SzhUfR4wNc#fh7#UKH>>5I=2a-v|9+v~S9_t<=7_I)W&j#cCskiU zN;k15JQM9KTL{tXl0qtF)RE~TF{^!*kIPn>tvzi|qOd8La6iPO`x>V&h;Ho5cK`WE zWUD)CX9vud`;@(uHE}>?I=J7v2ZXy`kYCdW z*J6VR>~(eu-A1r}wJf6gkszB}xc4Qcks|M?bbAk1^LOD&qX+56+;LZnRU1VCYhi)y zc_=3Z#`BO-G@IPFxMy&3Wb(}1*%kaDii}@uY=`8vO zFYRf}iljhMim2G(qG86we?wAjZqS@Z{vNs zJ08EW2Sx7zecV{1EPfTJ{GD8~az{-J=mkV)t)5W}jSF6F{Jr}!DS)4o3#OKX`PnC} z{C()E__5%(ng7(@O{g{@GkhyKe_%X7wq_8e{OvM8Ha#h^;C(jTn!ue-qzxt}8sQ+= zEIt;mTJFRfB!3#lr})Afyp6&veEt&s^Tz#-IkQ{?ragyi|3^?>i`CyDJpaWUxl*tz zzWwCXn)p`vi7S~ko#_rRp&evOLC-9!+ohBTU~j)z&6aa4%0RgvUx$R=#tZuhZ_4!h zWr!{PKp3`xaoasUPv(MzfFZqC!X7j53lo0YS`*N-e8cReZ6kary^cyGxnC7vcUzt4 z=MxRZ$=v~yyK_JI-18WXprls}d^4J84W6j@v7Or@Tc)OZjTwy8#|ThkD)PFD45BFe z86(&7$+r7ncP8`VBorRH5RwA)ir5aiu}qk3F>whwmVhb$|F8GY`FE1*5;$rP9EC zxV*glN(Oiv5dU@*n)4@hH#Opq^)WlS{a_ijA@Pluta)5{on8im*b9}gioduQ)dnX$72IgkZvJqg>fCMY{~mm z>W?gTvvoODj=4e+3Jq;C?ztd*`pEmKvvMzN@2h?eeqr5_A6a)`v03D7k^D7T?3lyB zi2V((ZCIlN=TZ|&!p29$>Nr+!LTs5k3AI@q&gFM;OTQ1 zwsL6DtxHTtLP7vHD7&qt|GPn4D}cU0D9WwmotOnUC%x+3#s+96Ut+bif;mV#&!XZj zSpBCN09YwjX^{WAaUaH~hJhaZ+rQ{)n4C(KV)ZY)=Ai~Etp3Fv(7yo1EZj0gknYm9 zlsW-O8_KOzRXY9TA?OR=_!)6)*M40%E09Sog5j|qT9sZBdb}Cbj5E=g=yvCfUy}f6 zfo`-~;|BdZ@QA+Z4%`BIQXK;E?QdrvAlfx541{}G3m8cvhR&eBcKq#Cq=H_>AU?ZU zKW51%11m7Gt~?l&$O+PNK#OZDPIRlbyo=%(2a1Dt_R{UeFpJ((S+@A?Pv_A^Z?LkT zS+s`3|D`pcV$F$5qYmC@@2uE|+eSEzVrb1`drB3l=D}V#;a*)8iJ%L88yj^OaiH6P zqKC5Df+sv^HG2+X#vqu`t;XHmQ1(UjpMOODSm?m79TowB;~CXXK9^9Z;BTe+^`Yt&cl;g zps@S0Dw^@(7y2lMWAyifFE{Wec`3USHV=)XtDolDHB{uQ%?yi}UxollK7)lM^sn$S(ewH>KQ`FV+Q*znFo=nnj=E2gOFIAY7CYJz`Kw*b!j_>u_@d zT37`+eA7*&2}tfwcyb0E1<(}l0goHRpYK$xVKkI*@wShbjz9h>Op&?FeI=#i3j#Ql zGA_)88t?6{Jf!hEWXYTq>??z*75r75Hp))_Qe2?G5>sxC?F={4Yj?LtYFG-nCs2%l z3C=fRXC{6bGbO$y{QuyURLab*zT>%^#ZEu}z2zVUQX2Y|XC86?%pW80 zfYL-#;oBLYNz@r^2(33j(sj+x+BPB4Mc2b%<@r7zIE^U?=M_k=Zo)xw)CfXih&?9a z+;&29lH0m)u9MT1-$eG1CzV(h(6Tq+ff? z2iD<_oRr$QY(P~S1kJH%NXYw*N2>I1te2@Wjd3-~zQ=JRcFjImFbf;6FvP+F95jY1 z5QwFUrOk4bC`~#M9P{=vP}d79+gn3Ur30-sLC2q)o)f-V&!>S_e|WfM$Z=J)55s|Pdk=Zg{5B-^$r{MMP!w07GY(x)ra+Z5z3 zndkRUQ6d?=^PBYs_B1(Ye*dAu>pxax)kV9eGt(_VzyK$uJ?T)etE62&|L=3QMLp&Z zYpO2G=>4s_dqpw8NmU;GbGRH5B8Lc)QvwOZkSJ{V`twOmBw~ZkS=%)Dmp`x{@CU9; zaH>d?_km`YUDL;we#c(~kmhbg`Jy1HM%myjoBKm*1_w6~8n@?6kFeIfF2_y}+RKFv z_PTEK^|HUVCKA0i^W&r(Lv_uLR+RT;X@QdpZLc}B$A_o2D)>jE%vW~GtOU=dCURF= zB}Yc+hgSHaOeJHadu1tlmOE>KlC+}!c~Kh9 zLek>id6VCpY1?k3bhWh`h-dd^bDusAp6AXiLG97O$$ygiwu&4=>b$eF2 zKpHv2HX;48>w>qO({~p+-A4W8KP#A^Bb1So-qo+>_L9=uW&G0b$t)%|&seC8AW4PG zl_!T+jqlOeC2I8o_!S|~cJgqhC2Y!)zJV``Ut z1Ws#K-P#+v|B)8BkyVUx(PNiFa-tXx_5t7I*3A@u+5S~WOmfh>Ku(=X+h{hYhASQ* zIdFsrh#+Bp7}O{N7D+hjWI8P-hx#?@0HYbdcR{MVrJ2JJo=?*okoc=N7#7#|6%ml+bY37i+>IL$(Vx%&JpGZFegy{72jN6t;DCxb3;k$G_M z56smOw%t*Mu_^)L0D#m75gP zyu*JR3SeZbHXZ>;eKo4H6-OXm*AqJydlchy@D60AZ(54da>T9h>vuJ6YE{D$iMw{W zZ9D9Ohv_klSfKr{!Ag?|dm*%pj_44gt0EF>+0;~DIn=b>B)Vk}&c3Uy^Fk>4RTdvR zfn0s^GE@N)LfAtuHKi_hCsVqU(#QAI>V%d)YKIy~#N6EPO=&SzZyO+`*W0aq{CN7m zCI5ZjF^ew{K>K?4u0AgeWF|&p6&KGRU%1mh?HjKK0QZL~oDllc5}3@9VSEIqhT5aW zNQTpXN_g1I_M{+(%YUn7lJ2CPmnOzYV~R482^`m8Z5^!+y7%_~>fE`8eB3|G>aRxW1RE_xvuw^DopUbbQYGy|HUR_uEcffwsUSDE zfPmzC{ce2RDL@?ipMr0PNYF)A!B^adN>ll&+jD}9IzS*QHj}HL#1eaZ!DS(-qX^V{ zvqCZ$wycW<*uYbYc6b9z9!B$2#!y?><92$2qOqJ@QeS&g&OJDH5ht^UDJzv`WlFi# zS_%nCd10c?B!QVIGqLb&X8ih*gw4v;iu&;OZ)!jn{w1_u>(FA|))}0%Dy86c7T4eC z;3p7FRT^XA^Q72Wpm}tbUG@r2@6~;4j&g-UOu#C7hs3S3P|bXw%zoJ=WmD5LzVrae z?lT`dWk~a=WXyufwhXN%%C!1fg$+I2Lh|x&=i`#FZWSws(&>V$d`5eSmv`En6Jef! ztecVBm0j@tWOZs?e?D|3*nGlsO!4>{?+0xsElDe-}aw?(abogP2 z+shC=`hGfJT&wZ5g1`iC%EOEnY(bz$<)ttLT^-2F3qHGP#S%9^sY}C^_x665TUwaEEu!`|UZ|zo;(@)MId?JJtLc3<#g7R@eW&RX?K8IFS*trA zvOnry#1`Loz=afjxc5o>#ZY92fxHK4cfZug{INxt_X%a2a;?SERu0~Y@B=8#e;4Mx z^a1h$KTNq|x!dU95^50(prQ0HK;!Ekjb~$DYKao9!Ah;)-p{|R_k}F|V_8K@h+?!l zt`deFOmMuty21|;TRvy90-|sbH5Nc)dMlDK4C5Ts^Vp@e)~a?#BN)Udr9S2PiSky& zUoJ1a?+0x4MyvU%*a`~&Ay$_@c9FK7dN2Dc=bx8nXJ%%c<;bu7;ZEmDJr_48JI+{M z{COv^XMe-b=cJ>+k81Gyh_11a!-FKQp4Z}~jgiSwA8jk<8pgwv&a4($j($t{6r4$1 z$0z#indqCC`kiVS*f~4ov+1=t-}`4P^pIvjkGA`?FV6Gat;Rf21YIq4)iCzV;ff<2 zS@GX)Io(m#m->4Y(?3p8D?@C}*P5QLf+GY{yKS^V$#q0;7Tz%!!@dJAd!0g_p#d`Y z>yZn=G7j@0#2Mbl&icqr-MTZ^txf*NBu}T#J$suF+;7_YgF2VaPth*KRE;6y1SGWNAC7h9IFIT))^^Usz%S2W7kQ+pV*QLohlW^%zK455-Ww#U(<=)r`8fmsE%X5g+T9XTM{vJtLvh(T zgmn;QakRRZFT~nUx7UY9uz{)?WL#4IVM(VxMu_*0q7xSeh7!6>8@2qo-u+8Y%a|M7 z<4=JA*p1gi`D~7+#z?<^%W&+jdcC;a51p+)t|K|EyKhC=$9A?-S9I}b=%46|`V@j>v%5Ps zA8`e_iq~m=m5rqkZYif+EaiNGQ<|I83s1uSEI6VFOI`4d=K{sI<>i=y;tOV!VysJn zFvS+reEvJ|ksZ5r}kui>lK`(-b?x^cWexk2SA%YPBwJ-C2v-jEsp*lr3Xf?C>Jixda>k90EQZ z67sB0uE#4*n!fOyY$OCr?Iv@t2+u1>rKWWE_01gCsN#zawf9q#N(HJ61f;a}%=KI! zO6rS(3rbmg%P*+~P`dnleTPL%z=TeBQZie{6sMflV%Sd`5`WC_@?5@i3i6wm608tl zR*PpkpQ4W$pA<%iC|Kzh$wWQM{OEc(aQudP0x8{cC=RF-zkE9^3g!w3RoxE>@mE0r zeHfrXRO9)2bgk4pC$mPDyIWhE4{I@tZ?(CrafLv1>y5^F^KzqzWnexSKtjIJ(3;)w_y}5Y4cNiqE`REbRy_xHc(!S#;~FChm>{0({X?l261X@e7lr z9Wnil0Kkopj9H)%-m9UJM;(+Kn_BcnmLTDjd)V4bP4yuAPABXW9MZ{Kf+d;p2=`^^6I3(#)C0D~xW}47hb&HhqI}rYN&08Pz7^SV9Ueuh zt~ieIk3_90#4G=z7ZqjSeq*utKB!>A3{5FwjIu35Ac3cY0t9G3zmTfWAP$026qKe9 z$Ncb6UxqN?7H)^0OD=^jbNpAOWJPPjhoHQbmEORxm1|{+ToKKF@?n% z!ciL0nVk~Cv9fU=y>!FI;s1+p@^88tU;V4-N;jb&G?lNTO$-D5|Xy(~kc zwWt4E+ow%}W^KAbC%UO!ztqd_EVV|Z^lbJ+M*q}Wa?)Y@`G8HO)JwIN_!Z5NsS#yN z%blN>aiZU?QYi=>c5}-5^8Re@DOH;|K0Rt6hus^!$Z|srv^4% z1#E?IsWofy*?KP>;1?)S{_M7{yp)XecT`Td%h>v(~HbBklE+WqHk9Fcen{_&x6`oS_4o<5# zwJf*b+T_l9RpQHJ+aoTwlXsh}S|AfD517RW3TbCr17<+_X0nRz0x3#C_Q+rDI2IzQ{NrB##<`oL>S+P@4 zUOq2KbeN{B^%q=bp3~vydw1SoNb@b4V z(_F))2~y)Qq0~6ByRJ!MK;jjFR4EyUt*-dfhi}V!pY+=_x6Zz%_elWBuWeOH+l>AW z4;oNT*TFluKpn@dF{&-V&>yHy4$Ksl?Ce%7g}pb$BSzPWA67>^7Fz~l@ty$N4)rq> z&hk9y?Sw?nWA#;(v*eaXfVg}b*h#)bC{_0XJSvSf*v=yn7-rlek>G*BTT3c+6GJXl z4`=;HJcLU#TF$mm!mTrX)9)cg@#IG=duty|fGFS@T-cz3P;@*-R z4F@Cy$;tm3nLzrq{EBd3{Ey&j!jH_4vO7D^WU%+&N%{XdABzCBX{<;+!tH@+-fNk- z)}K@VJo)r&`b~Xk_5&#oh_qQn?>|waqKL+PWp8vI%#aIypUynY{Njn<*ZZn3&Q$|E zwq6he{xZmXhq#VgNcwSv1o7*EPSPDo1W4Ck@tgzxD!Pe z(cB}*zZMI{l?}XGE=9{SCBZ98Ct!PK)?!heVBFYfXK>yeKkh#>8yrh#Y9+csORr0g zJxeM>RxSs_XO==0FjQwQsG;h1qE-4)!i-mL@H(k6X}Tl-RgafV&+Rn)n5N$c!icXL-Y>N(#yjyDlZRH)&tG-&X0Tn74K$CR_G9wG1Pfr{vOb?zw=(e_8Qj@ zN!V`QxUzC#twt@wmW5${2SSV|_V+2+PF(dp6TrXu$28{N~Ke55b0CJfS+=O$|{ZAG!^Uxnfd~GS~!rNcbi%uG-UYZzA8>K18(Y znK3Eip}h-}B~0jF-B<{FBSS7fBK;FRlDa$#9{(s=mE@15Z|Vn4H~%s=f2@`Yx)3_` z(0q=yU~DE;Ky(OD^SURB{%(73PT0eSfH>0l7&`7**VF!aLBWy)`^%3GKQg~(|4p8Z zkh3S|nq$!BO z$On9+bIB30!DA_#l~^2T!6br6zsSRQ$s9CD@fMc9?kjJF0Lf7ao`^DI(c|Cjr%^Sl#kPYD}@D*SJO- zr1bui$cs$aTj}gO9eCEsJBZ31Nd+Zi7D$aP`eH-m8lKd{C1F>~s@%<&)07WDdSK_X zH@d~eA>cQHZS~&%CRuWeJ3gih!_c*Lb%Qm0`hYoahLGVvT>_?H733^C*CJ9vF+&yNkl-*eSXCMs}~jhLMgf z+TWf92YT5v7WBX;fuP;me%^LkMVqkITguF3J&1YS8rfwo!2KLLq(dS%9Mj2%VQMkb z_{}6qjC5A4FPNk1QTt02pvGhn0+8w<*fs87pbxual5cCJAHve{Iq)&K;tlmYQg3HU zI>b%&Fo4{>J8ci;ovJD|J>v5JJq#6qH2?(oKW2zfwqy6?W`Q1=5}S2<11LR%W+dj8 zBy2pP<(H7#R)}IWk_hoHkSnScNrIKii&UZR0L}CY5nx(^;SOX|QLT>03 zYpQE%hb@kyI92uvayR|GZbV4RWc|6n1TKokoCT+K1F5!|fj=t~WdZ{3cxVJ9e;wwS zi%CMuQ&}&q&~GrikeQt{cIdyuN1w3j%at^COm?jMOM3-bUfd7jCM8d5Cfq6`L_5+l zEG;ol1XeUuxgmJ4Ur2F+#cE3$GBR*eUMcoJH)8WQ)&V|G_yL4ziB>wml0#4f&g7-T zDSjG4A_y6Dx;Q9q)!rg;8);U+aC;udq*AN+N9;nGIIQ6JJkJGN744rBje>qAs)XO_ z5pWz&t#;e7E=Co=OUJFa;#RHoH<|oQhwuIZ7P#r$U=d;UzVR&vFFS2cn^aBA)EZbk z>yA5V)C>$LYc2cxOJP_s&P}3hJ{OD5$|TMecIRx4^5pW@lD#O`J+-H;({{==ioK_k zkCvC!J$W>7u(kj72jEA7MGs-L_;Nw8yhtv5=JIrV^%lt2spKELIob zz3OHhJ{MRHxQv&&;yM1x;43K`x&67tkPri=p&Cl^;z=e6Z9PI3a_w(S#ZRy2fC+5r@g&#(76{QbJY( zE2NHU<3FvT}aSbrgG-5*@d8_|AyyY3*-&nMZLA(QKWTxzMMfnB{1wtXI4~ zWip2tt9IX?tbQp&2ydMAb$s4+Wr-YgYg_q)yYgx>8!S-{mA=6|3wCz7Oja(&>7%HI zx0h?b^_`*;brwI8*R`Hv1FgZVNP7SdJw`pJdU27S2 z7u`OET# z4zSsIs^mW!uzVS8y!JY5png-hf#j*b(ybu(#)~EJe+sL)s|O?=(}D9)N3tsMCfJ#V z=qUIo7Df}e{61cB&-mw^*3Hbd`R=`DJoLBt3BNfom*?uT5qb(W+uo_na{du^UA##t_OiDVmnw}3_WFDq+iahDSa)4|s}D#)Na){Ef?9<#Le`P3D= zy97-4_xMVuwm$z}Z7Sz0Pf`udJ!UDg!bd0l5fPkw{f<;Xt?^IU$bGKZo;!?Meq6Zy z>dVTt0eG-syW7s94rs=ksd7BhWWfG%I45IAbZ#P@wq<^94I`IEGSXfF0+8)etBy)^rbBd>jg& z&@+7~nQscSum}O06g&HpgbQ3F2LiJZm6)QD$RzR68vCM01_g$T_O3I774=?@#H z-jz^3kBU)ti4l{ervS2i{KV;sF@6_KpQaRQ&g&=ucNOfK)cuNGC#uuxd${OId&zSO zHsgnRH_O8}Y*dNP@it_A230Ce)pFUA+{)#oIcWYPEVFceIGA6I-S5`q^HzOpuSqO_ z4hI2RouaVp8(e+GEU?M|rFTB7DnKFt!F0Z*6b`v`ts#)-{1v2t8v02&^77O-9*>*r z8Rdw)y1BV#crM?G-R#kqmn$LgSHS0rrckg89-EyGVP?6o!2yOpY11+2=s=qQju$^% z@~63z*J9N)ly1aspR#Q@jis^nm@-K*|0E_b`fW>AR_>*yv+kqpY%e{ z<%8O#XxAQ}KL^dgze@bD^Nb4S35C-zASP`qxE6cpa1p(95w3fJ)5X( zRWLArhpdCQ=2W7$!}#xZf6i!D9Sm*vnhVO>Rd9@3=RdikYiD=yGLgl8NckbD;uRh$ zA9v43qqXGCOXw@?UYie3dif4MI&!AC2t98=tV-vvN$%(9k?cc+RznL*Jv^;eTt+1< zDm06uZHUpRH+wCwft$-FBfdjHVt%cyVL}(@qaTrN5v3wXtk!J)xEyuygL+LLLz7KH$f`6+jdB#eX*rh=PCa&l}I0T zKc7I3egDze*zRc%DGBi9VV+M*_`^IaDYBHkxV!icA502IIB3Ms0s`K4^!A93I-0| zIAb?*72O6Qbb>~`T*@{JEbE)x6*GykbASCKvAUMIQhhlQ%7@I)C;@%$xi{4pTi1%d|_8i-albci!IQ`EZvCq$}fj-CJq- zR4M_kvXi=(Z7g>Jr8&w|N}cqSTwUw*u&|u?pttHnpNHkCN_chTUnB7BWW02>93Pf{ zTH3hRhVH@5;QRXpW!5LrrIq*6_fZf0g@!+!Ff5B8^GC$dGvd!RO!trxUHv~2)6_CF z_<3eeI)8V!&fy3X&->T!$Qbrq^90umqK7=ots*+q4XO|YN?y~&`7CD5SW0Sx+_YVN z`qj-NVM%Y`9a{ki#fzP)G;QV=U=c*amI&OEY7z{N58pue#pk4=c9`{kTP1SsYd6LE zWSlG|%FkH^A56RMkQ<>d1264T;td7KyvZ{IXz6l3elhSIFkL;{B}3-6uh0HwsRvNS zE=#e>=;WHE^f#Dr?}564Yk&znNjvH3o#ge?PW`65Kx>!)aasIXD@bQNrbyy1oz%qq zT{Kxg))M0qi8=oAwq#sy6q<5;k-DV?@a`Su{ zl0usfR#t@&lwEmdj|ph4-ei!M(FEXe-509Nf67eN-}BQrEbOtrSv4n&X4R4)XC7}Z zQROlB+rH}B9lNU-YTk9x1gCwx(e7&e#*zqeCb3lZ_4|kMGIxi!+N75ok0Uz1&sEkT z$kxBy*`j~IC#F5dB&Kd^2jpx8%X^6`9(<%b-1D@UwQYN@Wd-s&>KL@r%Jv^g#<dZz@y^jGP-m zfvk@w(JwL~v&Mxqf0zSdDH4S56T4oeNJK{`+75%%ggFcbPxM6H-yT>G+=db8jDFo6 z_~7O=RcdrfB7>bga4WT96uW94wEWg$@i?Q=EIs=;waWY2fb0Cl^gO7Yy!{Xj5jn=a z?w&7x3`jw>yb9=P*jd^y)fW0wGjf```+lpQ(n({7RG*(7Wb!JSpQhWjeP^%+;x@a$W6d(I2g8T-ecRikZ6BKepX!v}@k`Ye@X@9Lh~ke+IP zw1kl?d=la^d_Uc6^3|sO(oY8p^n|qzTP}a|G*LYJhCr0P&I)8T^D(F&>e(|YK_7(q zHss!m`L67_{*7R$y5hS5{Z&Fzg{aZm&?PJJq#--;gj@#ZWA&!DvjJ7m0|mJqX2i*h zO_c!J;0{GQYf9KZk}Qmwitatc4XW%hmu~BVX`!kIAkWfF(jZ$$#3q?}PK)6{O7hNw z4n4d&%PSae&|I10JVq`hu z^|Xq4Yv`$)UgfZe15irJd4)1%F`eT{V`~(h`?2FO;(kXMABN0Ac@CUA3U1OZ+wYR_F0X97G?^DZ4J=aIYy(N?&dYvZFEXOwuRB1p!#>8yy?}Y zS4Z$}&wEjkHpK?t=}C??;bZcDLwn3DCz~P>yTd$|hq}V=va2jG>E47DwJ2PR3p|?^)FYV(m$Th(UzYq&~H3J7^t*Q8d34;o5+7_Vn~j zd`3v{3ZbR0HF7YbpWt^3zQ-Y)USV0k$Hu*RM7k;OSKZ{VmBceAe_PMPkHu=BMe%j4 zhq63$Q(l^TbXGGSo(wB!CbS2(4URv3OOaUFgvA;h`x2qAu_Qo8t7GC-kVtL9?%j>M zm)A@vauU)^SO=m_l=8&h90Rzf>SxoYuhMu{nPE#{Mei$Whr>ImgSNU|LXhtJpm(f@lU$K1-qwGM<_3uY*I)Qc#1Lc z+@;8Y=fef}-b+12DSVImwnrmJ(jKzUsd%&e*u&Vf=Hx3IT=&Qw7=TlU2zT{@J0jB; zwJT?k)Ik16v+rLWE}2lW4#FdacZI7#v@t`RPbIvKT^#ONQ+)Lg?P_UP2- zk=47ny`GTR*ews~dfEWcm0TLVnxbwmD4XIWi@g;-qGr~}7zoI7(GVBd{pzYRK7$!w zG1Di*JJ6lo_a>NEmk6Nf%h67kyMpNZ3Iam-@tA=Jr%KU@V>{h_S;AT=my1xO{2qSe znnpl5=SB94LsCtmDaWb_O@}~6qA#WW>#_0t_u1e?vLYp4^8tN`!JZyZ6~-(c16N}drOQ?>0zz$AcHw=^1u@saHlUrlqhl*IInBa}sW!|u6aB&o$ka_{~H>{Zi zgB|7-B{bs1XDF!*Tp{m-x=R-wNnlyWr&ZpBGtIkKXFoMxRPOemIo4k$N*A*6>@wR% zU>FF2_et&dY%uMo`9Hv?@MAm&uWR5WdEGeNo2Od{rr0e-`jH=?b||A0zxI>ehf&%H z>%@BGBtg-P{X6v+@_uxga0Dz10fzZ#$M7Era{w@JA^00|pyil5IZSxf0)Vwd;m5!R~D4w*=4)nx0 zR}oynZP6!TZNF2uME#^K33)o0g3J&KnJx+f? zQW%f=MdnwaXO0C0n#YDcZKh^JGg3dUv6^@uXQSi+*lu?#&YHe7I+=G)F$n{s#|{xs z8DEHk*cpvqL93$s!7OT}{T^GHeVNMhl0P9fE1C5;1IgcdNQlrbt^^|ry=Gmr_+`IlbGp-;~i@Mvr!R4ZpZYbJ5RxM8KP=SdxSXZRsyZlU)+c@@*r*R+U=aMjtc za+TWG{f?)vN*+iM0%>*MuzK2Y-_pb4)x0X=aKId^_B~^Q{w4^K9()aas&XV4oyC|Q~77>Lj z$JgQSEf7`>E)m+7Wam|Is!vg3Loqt6R5F1w-olt)zAxUrV`-$FQJBLzn4cjoc!oUPHb(f7S0e3sPdn_ zc%D9%3Fl22L`ZOCV6vWoHHu2)(h4t_Dn3hKJm*zLlz+Mn5JeSK^|^7P*iUxv09!NK z2a02#d>9kOt=VmnP$jy+t)e40Z(Ot^X;t7`Q|`n0Cy$qdF%T)CYqZ|U^AN_bnvCbv z6??I~l4h2@*k$IcWh;$G%}SApz5*vmd9-pCH4&|iyBX`>2UJA0#@+Oiub9PH=z{Qh z`sqD2>bFslMAh$6wXxkf82695k&J(TA}*UR&PMDRv*H}dVESdee`ppSA#Jo4#@A23 zgggKXPIEd)voXc1v9Bfa^Q5rr=8@@Rrt|ogXk2M5r=-w3Cw7eazT9mYtwrg2DFt`A zdkmZqw=~-c+iq0uR4zOrRxLS6LiAZ=qQ|M7KkyJ3Ecd$~;2ht(g@Q&VVh@s@8w;R| zI(Zd8BmyVMhe41OC$5Xik@=NVy%U-HM?DBSg0UUO=B5Hp=g8hWz~j%?c7j(E2OK-6 z-!7+p=Zl2)JoOQ+#Y)bMS=axfmN|pr0S>bF@sK+pe!3T7I?|))f8sO219}w?Cq||4 zY%||zUy{nEnhc4~@V_{1pm+T8}>4G1b%3-T(nFx>f6DrI*QRu@0O zI2+Cn46NwrsHzGGTs?{x3wR0stRE>LBo90jZ2p3{tRL~=QQq8?S*f!;Zj$@U6UawJ zEn*iF>w|-E@-3u627M(W$3+iwZ|rU!+u$PNHPs12Z=pE`OZ5vm{5#@DKKAqtB%{`J zJ)aaXaTv>?dqN`s{OU0W>qwi#HFpd09!waSB=Dg^100jl>Mlj86p61jo3B#`HL#k{ zl)#>D`Db7E-sdfZ!mV^Dg?8TtWHO1JOq6BGOPvAtu7xuur#cpcmd?c2J!?D+eQE7*N=y=n8N` zWEH%Oco|aqr$eZzHy$DDoxP6K&LA1EhSOA zQSN522mu!UwPxhzdxe$1GM+WPh)ee6Ez|sYmJ*Wa>5mcCwVk}3^Mjco?!`Po3t1fT zGhUQ1#i!=RWs+syhD+YR?8I58gP0!%jkEZ$wg!*P&U}`_`R_Q+zdo7&)zjCbItS_0 zsPC5V{chR~k17N=7Wrm83G~EAYZeqwnorW$#1+Z#p2<3>DVoh4e`jw6NJ`Nc#&-maBQpaA~A)~6StUeZt zjX=MW1|jT+t$2yEgc-du-?o9_GJDi<1Oa+t@UQ>o7Voj(L80L>0WcTDTC>?g%)8jA zdT(6M_2j8h1S8?ulIJ9`lth3@3ykZ+L=%fxhp9G4$<{aftCb~HkOtLv@+x`Eyr%9X zD}0*OmSz@e_AHCNm%SgdzqHc*aaI0kPdgjK3F%LulN>ESqvMv8{q+x2@)pexP88G8 z&ny_s9#^bQieVZ*uT!86j!BlKB ziuS=iCOI@jMmk6W(`k-cOxd;u$f-l}%)&7`j<#K6YwI{>1aN3m7<_lH+UtO3 zLm%0Dy~{*+w(mLF)fZmW2*y!usibBGhRG~|_O}}L>w7p|z|XHqIe@VsMgNYtd;y*_ z6@!jwIV1@aCyLHS(L3KPoIcJi*x}MjJ`A9~g}y&-iwOL+_xgHHK-l5D%y>&^v*`}( zKW{h00PNOrOPT>1P;D%@zhPjSPdKucnaK$hU#M9L2C<5j%rh@rbBO+t2L;8iPc|w= zMbMe3BBlBq-E}W=ejsvfeIVeUk_6g4Hzg3?O7Q;pJ4CDb;n6`2qJpuF2zdu4cONg1 z2H8d@769Y&*LZdT+t;?45aN-|z*rc8ec;4WoYdq$(+3qL{l=U7prC7-a8_=5>R8_c zf3&?>0n<+q4beqO`G6WlYNrXc1_r>R!8d7(9>$x;K@)m*7igZsc2V=1xBJwZN(jMR zTBiwMD>{^+jG&7R42VzJwwYK5kq+O*rKDgva4^x(4*>-`x<&&};#cK6fAz@2!_9w9 zA~g(njqmOiz68cL%JokE-~-#`O%egCjmXi_WGt+?OEGRl`aa`%?U{-T1A^w;Wi>JOImq8Yt-A`8ggV&CM%Jq`j;!P*>cuJ01SsI;;SGxCv z>KE;Bct7kOa(N}{2*&FQau57nUCAR8!GHO`lhOtb*HjS)xCv%td|?ZPZ=>jGX|uuI zFcdsO-#*P^6m#meJK1|)A_*m^nZ3a)5WUAcpN2v@6}%M2n8zWn@_PEBN1oGAC+T{A zKw-1NiwG$)f>9dENOSY-9@PkNRH&VaVCGFhVBO|toDJ}$GjW+2ccJ`@FD>=~FCZ~F7h z{gX!^#N6Y=AYTnU(pGn#%jrR_>U*V3Ld<7>MD2Oo37|*xW0;H&m`zgDeUn~|a7(^2 zfd!K>MEdNzI;hOb`{+r~Q_HY}zxs06p91*wE~nv|7C|>v=FR1Fv@@Z2ktcnBToDnt zNGs_O{wZ!mshJSGZgf*IxO4DrDJx5>+D5Y1?gpRm(R+b7k#Nutj%97~zyj^Er zQZW=v{j|jgBGsD-<*?=4lIa62Cm;eS`vb}h6!?J)_OJ2Qw-51{n%2v%+)Iw-Y{v6e z>Er8w{oR(nVeoAO@zBteZBS6=u|Fb$ZaMj?QBPBlohw6*Klu1L8_ zul|UAo{F>~R_jhrue<^aawy)_45lT3wsxk@C{n{}iYvIMtT0casE}7;j5MKb)#?7i zNrCkZ?Q_l7FSAKt?V`}&*5yDV8-}i*5sY6G8JjKn(fU+9DCbSo+Vf1q2uu{fQD|C- zv=YpwR<23r)PRBQn0Ccu#*b$)5^hGUe9`csSS7Tz1i4aV*m^p09l3VXwz=Uc*T3zX$J`pQN$F|916jO;qr>_1~I-8czmo`K6T6XIE78G)2OenxV>G6H9j!$vqpV7Q2n;fym+Z-dUGJ{y;Kx6<)Qz zQJb0_o*5Y0fw$2OF8a^ZLBtfO2ZL(Vugc$1zp?voQI18f7Ie!e&kd}5P0Xt1z(2zw zdj;2TS+iD{jh6J6iQDI<3mZ<9T$5Y}!Q&074*ndtPck4bXI;f;W(N|=X;-RnL-Qm< zFy}K6x2^O6aQp~`PSQxNYEpP4&}`AM4-U`)y^qoRJWmE=J-GK)io=po(3;H=l975+ z8LzNuFwCFWuo!z!)OEVFeCL>j-6()`I?6@yWPbI?V9y&9D>##;aab!EmCy7 zMla{7u^(U^uONUXhpofnY%D#-k6>kK>dTx@cJ@>=bK$eRqwL8Q*%fR-*;GknQl+S& zOpuge4cN!oxFyG73^M{!d-^s`z^_C6A(+)Zc_`!GPH@)BeCTK8;Bs>h9(9-#g5!pR zPDYvStuUve)^ahf&eS7nSXN$16%$rVjAa`lO$kH=@ms0tfVV22_#!KVms(NiliLRNy5CQE(3T}M>KiymW_k`8(RADv|gfW=-t<+b( zfS{5=FSgQ)*UL}^-8}WUrl-l_Qut$O!DJ)hDkgSWjJI&|VYf2zB9DLG@=$#Ur@|rj zVfvYGWKUW3n&pZZ+TsZH9C7(|yLH`_Av@C22c~Mbivu?&9cJS#d2zM>_uX}|aVw^2 zx0Qq%l0XVbjSt!xqy!Gy0cg|J+cefS?B}H*in?u+m0G5?@Ah$lc36elMbTHlcM;68 zP*WSGwuU9c5wRvvr-|yvgT9rZ9~d1&d~+1A=f1-XF=>433P)U8`uj7#!L^!dKOGQ| zJX%`>qd# z(BF3V1x2lO1TuAsQzJh)D+qkAYVzv0!EFzh?}k&va&vvc=>9Dw8w9f|ZaISJ4^Sv{ z2fAD#aSF?_^Klq#6<5T%0Wnj@dP-qL-;_eN(c*qPqp(-C zu|K)ntJX#!TX<>tOHwm(828i_$ChqgR;;g#@=TWB;e(VWUo?a^zP|;p>c71J8biM1 zIZ#-`nuaypAmL&{=u{S7I)%>^)5SR3uk5?|_5t8-L?fPpV_qiAU+@0*;U;^9>(|M2 zKffgQ<~$JZ#c_Te37m6Uh{X+EZmT85XI(+7W?xwvF$h2jA~jk@8Oy2E(* zrJNR5E@-_gwAYv!`_1&If*AYT)OZ-Gu72O0g&Bi+9}Ghh`#A?Us6B2|q+yT>qC{d% z8{aN1zvEv3bxxYmmjubx*N2b!!1e>RmRGj8&3jO7uX&PC)5vLvhFTbkFCq4o_FT~$ zWWp^{N98nvyn(-3+UbS&jPsA!aRjg8BOWk3YfoV^Uccpzwc5R<@@t-7Y)_O^_S=W3 zQG#-YkQ1Y1Thj4TlGS!LWc|8EtFrcKZlY?;#yfG&SEpKl6VG)iy&fopin$7*c1iOh zlT0OR38sDRGxYLxmKj|_{5Gwxz~_KMYFgnP9#oMsmcW0Y2m%A>})eW{~Fnz|l1^>Ij6nN$mx7Cq4 zCl>>62oiViG#~scmidhpZ+ao3?XGQrSYRd>A*C#oLHWn`7rM@GS?6ya`$@XGCkYAR z)=C!h9*Ab$MX#~F=C7ykiyI-o(l}nTi}Unv`>w~DtpQz2V zn$i7v+&Ni$_bl)ot?jHxt|xsv!exRGO}^|O<-+Q*W(kL{!=i>Z;nw1QS23l#J~lqI zZn|ow!bYc_&XYw?MU)V}v-OBah&uy@X31tzPa4}dC@&+&=h4z2@eUNh3|N43x1du_>ne_C7-EMb{VWmCQ$> z@IZ|g;=KCPl2>v6k`o}!X=9-n$v?Qn`pm_S|729hm^OKhti%*6p*4DvO>-1d(Z4CO%GT%~q=M`o49?D|7cGeDa7?FyMv-d(xyLVk26urPzwA%fN!kOSMn$(WU zp(}fzubWnCm{NPqcL5RcqMzcWGSM&H#|lBsv|@MAqQ$()>`CV1EOk!V95`b(Ug0A` z(zeunZurTV6N3%=>Wf zX^1}o62c~bo>xA^{mnUXn^uH=%_2=OY~1Fj18r(-7uQP@qVde&BJps#+$(YiKpS#E zf{#wu#F7GgR^H^Luun{%FcSm4or&TX-yV_*6=bX@B=t0Tkg0)fkRx{r>X18Rw&1x+A)5@-C{03IAtUk~OX7tizFG5PO8DP?-Ri*o!mXY4 zB&!SDX;wq)P122U$(gFw4pt?!o#P)ib$Gn2ye&8<*Rt~~6oXVMaQ#z|`H^tFsLoL7 z9@|sv5~`Q1CW$ijIk@W)yrb$B#Y~??Y1LmcsBRfCCnqLsXKQ_!+kSrM@~%u9!RmoV zGA-5k@5jpy0b+l-ohEjZG_jiM0#87F2)jK{eIw2dQGvz+`uYYSq@=a`3kH88agDE< z&5aR00)uDX*KT>Av1NCFjj6w$+x;n|@b`Dsap-ZElXr;zNWq?k^V+G| zZ|6Sk^&8TOUo7{h{@6KI7H)-@Xx@7iUhDyj7Ykv;vAo^v4kk}ZEj0aH zGjM=3_GVL*nYn%@yd3taI3W>qfqVv5twLn66uSZ9J#i8)DET5{z*ETBrjy$NRVgM3 z`DDhVj@^_W+ux_v1v*T_crI3-srU7L%22`b{`cU%2V7slt|z#PFv^~o$BbfxZME$u znFF(8EQm{f!mBXC(SGlx7FTccQ!%zFPz@joo z*|L&G_y~0?uSIjKM+)=Zi%cy(lKc$cIXPxN(zE~2Q`MKYY#ctT-+2k{NF3}vB9Jcn z!}kS!v@zmI!dalfTH$fJ;!|pH`<>h0FL3+|2~C41$CiI?j@mi#Nu#}JEEAoLeZ%IT z&ktTQbDS~eXkw=OaJ6K{cvS@<(sZPE}pNdU)W^&%}Gc6Znb8AOY>g2-`CI(H}VhG@Ke3BNFj0-h41^0hAh&_%qi zIyU$=?5gN#A($5#_C{d2!tTA*U$M*G#%a+Vzr;! z*?v&{WzSa9FF2(9!E7L#45v?)ZK63*9$D^)x~Byi44S?%wM3iHpIc9~j9*>Phj}QJxetXR_cKlY2Kwzv_(G=AtG`hbpX>_vGt9XQBZ;krT zWzU#Ch@`j6#ra^ePvn3qW31Iq>sKpshuL9eWwXB&?CDS}z%T%sSoP_DN=_5;n%D|2 zwpp3_o0E$6gUKqoDg)q{x*3fXMxP7OGz=?YP#>ao>+evHa+JwN{J9>^2ZDl zY#OH8c~Mj<%XvFaM6HDmL>jWy2fe09NT-Z4vhL|w_bW_xT2s(58b#31H{&{p2(`)H zCCWSBi}=ii_L>%X_U4|IIxi93!by|^MUIZYYsvVA1AIO8=vgQ)KXzKj&pq+UqQ}W$ z#NQ_W)HF$wr z^P}WyVBxg2HfhJAMS1*d-IbST!}@FC3jAhB-OF)?pqE%s229Va{w(3Q#+wc0BE|2cgNZvDk)oWHh(B|L+weD_F@4ovKN@FueXF3 zMZ9pu(Z;*N4&vzN)PQ^SG*>{~`~4th?XtQeg7FbzvXJexG+6RK98q_@h!u}Ij%HEW zrq#DLU^d?UMTl7JiH`xJ&I|1_r?=lkoY&`Cz%S-R-P1p3gebwDVd3;&-F-Q2^SO-vqPdx91L+lh9u-@Klu#vX~Ame-G; zds`l}cHcmt^EPwSfvX$P{w|Y|W>o%XHoPl^$BR7atF9l`%XdG&_5y1t@Sz!T9I$g5 zE(;w7WTYmCs)upV*Reg%$W*LKqs}s-+Ff*Zl&mA&0q@`DrIgjv^?NyAk7xK*dwf$f zWgEG)mPEQYamYSQ3QCJ1OxGU!7|vUeT>Xo@^glgAY>N;i(bb!jYCe4XJpO=aF2bPF zSEJ64sNj=$2 zidvc}9tZkYsU#^#?DoWW_i1pyL~QkmrgO0d-jlTXQT2`Y*_c zwhsu4Pox}Tr4g1KO6Kra0BwaFN){ky7i1Zh1f4RqUL zs$bmS7zaB%q?@O%?Y2O^ystfoZKKhiuI^#nDt-u#SN7vHKkKRJInU8^H zuL2;G=*mbAv_b1TW(8eFV%|R_lLOsBQDm<5go8lU_+e~g{EZT(b(8OZlrTB}W4w0* zrpEhsql8I)j=EClw|kjS<#?L^+1vH|_W@I+-+;LwD7z5o+ub~=wy<4Y@9HQ2ARZ)I z?hp{yHd%G0D(N&@xtH_TO&VH444^+dbuHqjq&|q>|3#0~AXlfJ^aT2P)7Rwu3VIKg zaw!KpO@)CwDTfPkCoQJ$Q&j8yU5OJ&>`Bo(b=Co+@tZzBDJs++hgo+`B=I(ig!>e$ zZQoju;7I$Oo%&$W);gSh@JRYd^ETUATUu%2pnE=>c+s5m;fqW@IR9hQa-zD;HzSbM z0@5(dt^^H(YpR4idC>TYQr#wo3P%%rOHh&wWQh?|GyNXrRs&6bIMJH=K%Cz6F|NW-gB zxV&%=w=N)ZoiO?Z9zNZi%4Ms0K9y1Go(}k$-}7w4H0zZt=e{$jteKP=XxmM7^K1+5 za?@B8&@DNUOmNkBX72TgDNP`^=Cv0}Vr!vK96GFRuJ&cs8)Tb)^?_h7W29yw2RXQC z?>VDmP7V$+ij3SHZ+7$xWRbQPgP^2rp-|lj!kqPy$2PM@nE4NHL|{*b_1G=(m+6?H zAwI>Ez0=v-_i9v~1z9Y?c{i=r0K}JR;~t?A>M8KAy55YlAjZw9F!nFu4Jg}D zLSv=C@rDl+jC*nW%7$DRe5hYG06}G)u;XndF0|swb*J$5v!1{C|{pH9kqsavgfs(#HG;$_Bf(ZX-lL49l8XUAM?`wv00c;lN+57+K zI`eR-!vF6}iWa2B&e$c{mnjkTWE%=ob{fkJqO4g$){HfK)*1WGSd;8y z*BFiEIr@Ho&-J^m=X(0LKgPN5``q_ApY!^>UwoKcFvzj;`gIN-N`BHU7lWy)AF1&7 zo2P5;6&vNNQqA)D`OR{JR)X^o<=c_bU@*Q!3**c}bvw_WlDT@&m=Rjj#2On3lhvxG zTl3}Qn+_#N!O1V=!l+kd)MNWzPE)$a$IOU2vcEZ2efNp#rwlNkN- zl|5%8wn$+ZKs^BE3j~vhY1+6U5H_~i$DTR=j4dpG^Pa{<5=P&kVZtrZt6(Qm#Xa!& zfnANAI?Vy0lnMJ>32$RmWMpjNzBFyw(uqbcCsLF#vI5ONDN232_D0dg|BDm*^wF8|9l z!nrG}ifC^8=NbuB6sn1RJPfWG`d#1F7be99Eax^)TqCDy&rb;rk#pAxSA=lfeam20 zcI*(DHT=mOzaBQ{q9G|l_@9CLGH9Q5J_V@V9+q_D`8TAFF@7(fw>kMH|7z3riUCEK zxABNhhwHGrPvy(=ntQZ`8+*Gqb~ZB4xk20+JBw;kSuSdZEb9KZX-Ct-CR#-V; znQ!z*?4j4q%b%kte|r9KSU<*S~fR2N_3Im2HKr5?vbBAFyn9w9H#`tfi* z7GA>&%lsob1FVa;5Wy;6HHY^k)Yxd1*JldrmczIB$w2A!?s$H@-L)iY0(AAm9fS(* zNCmkm9EFLNZU<-7=`>+R>}Hp1HLh}8{MRSIHVC4Rt*OzjmVyOcKHz}`X@TKamoJ`4 zu|P;Zu#e*E`(LIDWxZuSl~K@iAsupJx;PAEx|#XuBb6a34m__;Fpv+CphkJ#pIBJB zS*o-QJ-5s^^5R_AyN7o{38I&vCVH_(wmVyUA}{4vqY9VHpQ0-qATw%A4*QQ1f#8agp>>T z4?_M47v|<9aj97y+K#@p>rTG4GJ&b^7ocTCoEBI#eQ!9z38qB9Z>8pb4o|%$yyp2w zDvf4MIl*q)g~g0pCZl0yT7M($%$v90oO}lVTKHupCD<@m8WL@0k1A5W6fKn1z!wmLnXxS47IDx&F*ZoecmhiQcf>nxa2+jUCqJ8bt~Aeu4QMY zIYwf0f5?`JTX-vcKWLDbLT7g8xkT~6Km*?^$ahBU>dpEI1UhmAUfdFKa^EsyH`r6* z9e9K=K}p!>VM2ez(;&!SC>T+M;(+vj;1a+Ibrmf#pR?+h0`tWf6!-a3LQWi!;M0?M z#+8HH%Y4bPYhs!H9+(qlv=NF|nbGHx*C|VPnxuT;&Af=C@SE<=+#z)n6WR>G;;@Le zaO+}xq4o7?)i)1~&DPTx>L{%lO74b!dd=4@V8*@{lan-5ZC+7B+IRk%>5=o%4BOOH zK4x_&#taAFQt$0a>}C4^`06n+rCZnP1$*mi%|Y(N&JBl3rE8*3v|l7;m%dU&zim9^ zi8+;Yp_D1;U6z=npiuDn3?5#EVAN;PQv3*%mPO-2-L(Vl=ePFuy=V5O&Fx+anJA4Y z(ptn2wPe?QlEc%j_KnZUP_` zqj)R7$5wW21z4Q=mHCoI>Kf7kWO*o{=-kxhZ;&YZuxYVZQETZ@T4?99Ra5wd)R!!T z;oV)07YNdq#ky-x>Z>e-YKUUX#o}ky7In;|397Tjx-WjdzeBo!Jos|l3N5Z`WwlnV zVgj9!ir3v_eI|A)=9?RSHjL7eMn;pAV*i0B494XB2rkeQkScn>4FXAhf(9h2J)rZK zZM+P@WAEcaHk>Upyj?#ujVtqOeZF%Nw!Bdy{z+H-w|gDCfc*=Tls1R!V$wXHh(#Ld z=mb4^WrB0W*-s$TrG|zHq&{bzdh$#4;r4S@!O;Cai#7)A@Ws??bj4jEPY>kLb>@eg zu0>s=7-hYI(8uI<5-D*}DmE$Y_%b${tZw&JZniJ%!0Yqn`f!>P@<&btP|G~u00AS0 z+Mr=2+99D|VOf=pq0xw~{y%_wC}bl~_|bT*7(KFMtc2ZGR%eV!F;X_pX&ellR`l&E zbE~6kh5r4Boq!8Xl%XOv*(2LP5NU4c>My=a9;4{Bb6fQQPW4Z%G-`)WQ2u+u?TwuT z^r5)<>4zgMuLHhS`V((mJ#ArKFe1L4y7|~>;+1OVb}%KP*qNHZsci8iK-^6>NR_H? zRkSysGl2m_rD)V&VFks1B@>aZ*iid2SPKAZBt+t-M(@97N(+zy>CDcbw&0iUAY<}< zK4rdPzW&Ev(fwin<$rVj@xSiU`(vYnAT;W)C$CBR2w>A3tb%xb9y37Ro&q%crqoFo zKn2G#&2zr|@hwS5oGcxolYMl=OR`!H=w0s+?`|`}A+^<+B7-3v{(|Y->OZx%gto30 zZu)f6z(&>^8Hv*p+cOouTWlvZ&rmJ;5M@dZW`R&Xm7I3P{QPB$l0?KaKrFj|^0U~r zJ6Q{?NG9!of9O+8E&?^ytgQp&WHn1FfRTKygXC)66Uxp+Y!?OBJg-{<(B}2uwDOeWdj=_pT88EFeaDx&1Zti#KCEuz{L56hW;u38wWf~sV&eB4_2H| z2_wO0d^!E`UTrzg;A7l0ovBaUOL*rb9Ts0YEwj9G324}@^-8V$j6NK>qqeo(Z>E8O z9y1ytpnhNYzRz`25su&S(7oCGdjp4TL#q^`qDh-u%9Z4?3CpF+!j|KpJF!rE^HQz72@!cA3GX*3u71Y~&B0_9mM z;$cdfzdq3Z!+kf87i< zC+py*WA{WwN;xW~W!DdwLGXqrcbO+ihQP_PkH3TXEbQ~(zX&li$S*8d1*dtCx2{G; z^xyT|D$#n}5)7sO!^gRlU3M>Kj;}Bq>|cx3*Fw~Ly?XWhIUt~*HOBxsfe*n_du3Na z{=?bIr9iRJ&oijun!8`;yKkWVX2HNm3zs#qosISWwP!C}<}1*CzO{lb6^;Zy6=PR1 zBZj<9OS?)xOC=55hIOq%ofwcnrmuW&{mI;Cao+#t04JLl(*Cq76G%#AN1*idbqz^D z9GRe(B3dA}JAXx1OVgkt1Y62RZ+GFD@Nh@eCzZt2CcgIsCN5tsL8{JuJ>L!wb~FeD%J_#Nc}n)vfLFuGp`zyP^jNhRe*5x33IH zyvz^WD5YmTG?(8r+oo;J?;B1~k#D2G;&(%jE)e zgJ-Hh6q)2+1LC8XS(fJ!Of}>&VR~eBUeO;uA|U*G8#4iYW;7TA`yw>K96_UW@J#4~ z(*`%l-(O@@MbIV_8UCTk3Y`CgChwuw3gniK{)Yt^oOeV=v5cDPSeD-EOB$Znq%e2p zCu!g6211Lt6U;{B^N#!DBOM_RpWC?)`%Nu&e<)`uwb`l799T(Gl<_XDuYUNrmehIb zWW#0T!lng}LqkI9&3%(+&#S5W{!WGCQ8X+Nm?7eGv&4eZ0pGA8gnvNna>+k9+2I6E z?s|>|;p9drA#*(TFzml@GGlNG9Aplsq=3=;$E$PG`QMAsu)W1o|7KHO{MpF@(<4os zR~wC;VtR%B0s{lzr&NT7aU*BdEN59FhfcWSC>zF zUA%+gA6Fc0?i&OmrgHN8O<(QJUQwMe(E*>U+o0L0I|YBM0^v?9pVF)Dsd|&Ib0L-T zdtPHaZlvf4Wi?4K8G?v72u@g;jGk@y$ZV$_$c4OLsnwjnq8Nqxh;Q%jkBSPo1Jdj? zKG@Tr;NWgThlau0JyxpD{D-EMYr>~u4G@wbb}nBCsxD)vexRbAlJV;ZbYWn$h#d9` z0;Q!vD9>?-YwZ4Is}VXZ7?bi)7~Dou%$Om|o34FI1Yfd($S2}CaI8*YiwC#Y*DLfx zT-MgPsj>g2#4~`HR(w;HWGS3DL;)m=$TdC2~f(FS+^9mLRg!mgdGqMzQkgp}WM>`ibPtwF-5 zwksxHehr_khp=XbC`xQAP!X;y;s4>T6xBPVBSNu>M@6*D2zVYDS^fSROoU7p{QbCw z?^(PM%&-cSV#G`lA1VDZC_o^_ksm4?e%Rkb|eEK#BRs+(7*N}zO1cd1n zx2hZHQ+TfBgPoe?Z*u_Wh8|bXh&~Me^jD2{%f+wB>m|5x&|+Kv0|%$45;Z=bz`^r$ z*wRAqp(hxt_QUB{c@}ynBG|QMtrmot^bKAZ{bUfNhPxN zfZ85I1A||iOabfWJM>Sd_uEv9#Cwz8E}`q*B7?t)*{2;-Y{((tIF`0Ri%39etctX> zj3&BJg|N1^b{eEk(cP|03}6eia5pPGM73?Wec=hhq)Yy#Y>7jVADn~bDZlcU7fg;U z_`YAZHeS(@C|q83CjjmMcZ5!0VXySqkUJaqZ6Rg}iz&C^VCa3C+$BpXilJ{a$bEiI zCc27r2;tV>9+0=jaA0rDKaSYR-{OzK4Hi5wP&g_6j23bwy;XZq{9$2B^s)IOw=K54 z$157J%X##o2)`hfc&8@JN)_SxX94Uls6gHRbny;p*Zl;)ge`e$e%|x^_uF;IG4PV< zZDs5>jze6)Z9^_NR;+YIEwYnUKNkj|$D!LWP8K%7Hg49X<>h`0Pi3vfp*qt^k8B>6CJdf{r>86l z!t{uq)va2JBk)!fr`ixz5{~WN;^F`gHaD!WkPvlfbjz5u)*uIW<`!=f1H?TJpIsF; zsgjNBc-}Qa4i(+ZXJ=(PiX!M$9P|G`i@jzl!e`gs6MxAJHbo6+g+_DLk`)wkPkIZu zqfk8HqrF$wew$zx$4*uCZI`3zBML~(A9<+QUa_0|%M|cg$e*~luxTWE07PhwLmjqC zM9auy8lm7gUI22+g_K3|SEQElLT{qRO#VnrKr{Yf#q(zWu;Rl#xc&veD-ppILNv1Q z?OFueo|1_rcZ7UEG#wbrOA#d6}RsgtZ%5zRK6q5kdEJjSKR-bR{epDB4O^z z73VEiJr+nxY&R-;1*nl?f5EaPWobIRyxq@C{(e-y;v*|Hl^Ndp%NZ3i0lgmhY>sbA z6{y^OWt|0+A-r~GVFS71z1vzlLH~;@W-l5Q{!^N;XlQ+F+tu^6^@lxQ2Llp84(;d6 zL%BFp%1h@6#%RcYBBtMW#WpORpv6WZ@H8wX3ntL{4=p}Qs4Lya{8DIEpq#1^4V(*x z6&t~s(P1J&}<((A8vg7zU4*Af=Zm*)w699ah2h%o?;4~apvI=pt*5iIc`v9usU8vMJ_u^<%<;o&XP{2M%)Vn3#U3P77Bd92#j48 ze4R{|zm@rVLuC_48{&c(x*Cwq_-yufbaXg2@KAU)5&AXlq5H=lvGaedk3THMjhDT9 zBFD77Bc+DxtmS(9e-D9S>zWs( zj>jfJUoY`3Epw>H^XAB(eytYyv-(Qo$D2l)&-PO`^L7gCv%>0~Xe|`hKNLNXaa!6M zUVZ#{Z6$aClRw(F?V$2)srtqECr-%1a3y2<+jnV>n~r;@$*|_$xa!Rt16orHEA$sv z7kHr-rV;RGHppj{b@^yN%pkNU<#Y|*YtTGQbNRqPw5?4CER#vZz7>#OZ)R5mZS)^$l0%A!$`7GL0OJ$hpSDl6!_T@&yW=RA<1y?}x6e9-OK-a@bKB)>Rv+vDIp z{(QZi?qhXl!dhZ?VIbU=`>z=_vH~0Kq;e>3iZyB1j@mqZVq0AS&7bgdgq@*?=isUP zAr@S_>SOQE${aig_1HZuUrPYUgKXl!e{HKdd4-tV)G3L4E=X?* z)UaZW>|keSSMGuqW2#OqDTZ|%FYw#Wp}Dqnxc_uDSyEnKbF%N;P8p`b(Vw3&dVLq;sIaavUbJbXHMlwvy>Zz z4GyQ_-p4gzCv$zIfl#qk^Fge&C$n*s#m4-MpHr~(^7XKO>v`I0^f-u;Rzmq(<*Iw>a$M;J&!)BkI_FAw!QgUPSYd2#nhdb2Xvx=;`54zT zVKgh+wHAK!0nyR=R^nQ&GCWy4mz$H$ETFh|ERvB79qM7iy0;9H=q`2IkCr@7W&(Wy= z9`1K=*d~L_AG?ODf8u@OA}Ij!lZht75zpE95BelD6bYeKbNsY+gX!8jTO4!DpPm`Y zbNMCr@AF-ajbuVP;Nwrs7$1I`otQC*j{nRUk->0LQPF(7KO8?6gQahqJ_WqRFS5D( zZrf^;_|14^d>Tw@=$-^uN=t6^wU=b}t3f{L0r1E;u9ivqr(_McU8Sr;UN8wNv>Xk4 zH`Ck=hR+%~e3LJFG4S+(vj~nM^1XsprIkliBbg>XHP8-d5o=0Cai%rVhWYecJKr4U zc=RrZQia@yThkK6u)ZJBFv6se_>dDHkjbR{GOBCv@{69EPVfxcFo`{zpc+~Nguw?p z19o8~i*L)*+La3XSb$Y?8_#W27ig09B|N&J3h3E*I)3fxcIcCk29;^>T~H1KU_dfc z;!%5hWp0cgxMQk)%|_+YsU?X&18);K3v^_}Vb@z@X%dOIW=$a}eWu=D(6Moq^tVBt zOSmW)u1MZsCKd>3OSyif8RFJNK+sZ*a_)bFQMQj=Im2Y5=2R^>YOmk9lz>k8QZ#zw zt5kleFJP2g+lslXb!MckJncu8^-9!7y(Z2(rA}2}YP@ZVLOyfOi1-iv_VWN7aO<;) zgJZT~2{wV(5v`Z1MlGx~l7F=J=1Y#d8wCb*V8fZOyix9_j~zXdT^xBSdr^a> zPweHSVuCoYxWAzff#()xYgDOZ(o7k3tvGtGiP(oLk;mRL0PWtsl6*L4|Iol6Dx(l;SbC=$A7N8KghRX zc+TZp9M_NRpjF3<7WKnF+RNn1zW>f8xf(}MhJZx*g%`~upxIE=KJ4zLQR1>ZFQxL* ztk)?~!X41}%G;)@zRag`5)%7&KlAMx#T>zadOfSOng_ido}p7~z79_M}xJm_+jr`ww+pveu8& z4z|{W05xylKYvw9qYh`Z8HoX&_PT=L_;j$FC2J z^fGr4FxNuPWlr&Y)4KSl@7FK;42k~l!`C}lH2qTuJ1OZstNaBgg@frk34^b>zxNq%0rq?WGnyHR zS8|kdfnpO;!iJjxNf>mf+y)&gS7&MC#6-xFL8vY+<7GbYO(ygwP6BP)Svy15Mmn&I zV3zMmhiVqz!RMH|dt#7;PHWjO!W?US!iMpRgvG1C;E(obF9`P7ceN(xE&9#hsluj# zxMAIug<(U9jd0bvH}1bwiSxQHx#E^MBov3-Tq2a7H3DN=0b%r-LfWU9$%Spwb1q;8 zkz6FGoF8s)iuX2|fp`+F!aqh1W zolN&jIXYw2)uv8tF2rq_jjMyZP$f%(TM63eHQ@h;X1-aeDO!}kWc0elkPQ(Z;a#;| z;A&$K$M)K%-iB3@%ea;kGks-x(#r4?s5)Fy%I(o)6Dj`y-^ARlF~%Q_g>5Q#qQyie z3IGYQr!wqQTTDD0lYFl>UDf4$TQo=!+bBIlS7r98ZP&c<4WlGzwu0qdi0@r(d3&*+_Zl$-p`;G!#7 z2S-T|cf^eUyYh$Dc4cfLl$bqt;CU6hpgev*(|SY47_OCVM}|+|4b+fVBdx z%clM)M2(*CmF{*-g+1_Fx!+fTu6*3hH?2)zQ_Zmc<`wmUe5+d#x8D3E(BZy^<8(Hh zv%AWw*&|Yk##yIxMRYmEZ7%A@zao-c&nHN#ybC_-Se~~KaerL1H-7I_$}B%eYD`qX z@*l@t?V`5aA1eF0r$h#rkgjZY0Xw*<^^Qn1f9wymuySB}>MO6}7(Z(>Z0}dPhCRc0 zE(l&6TV4Ggf%#zj0IzyJ7_@>xMi#jlr2)ePCS>oj)+0PV^_iJ`n(y5%ab?v!x`x;5 zWV&pSs{?g2IyE@!yX1XCs)97DDl-4jJ5e1sf86@V z3V`SXR0Ow;nP+!|PKCv+d~vr9!t(=LlC;~l%Rv_ya4C=Ej zTzxx;UBwK$Gos9A?+}1dTJlz#O1;irNthQ};~jDBm^^4Mq*D}&k13oO0hQRzB;J** zAnlzSu-`>z2$hAA(KF{Fqpt%tSa!%MEG2zg>Kz#1ZdzAETgd&ZUOSD_rmZvmgoQWp zmJ~2|`5nQ&*A2`AYdGF+q(b3^w)jk|=@x*QOW=ExFom{vjZe36s4YfiFW~i9I2rPk%aH_S~y+YclHG^ksqhDj8A?GO$}xG4eK-F8!8+q*jIE_=V^xo2=eyd*{w zk2M|V?a?Jy*sDZ(cpDLeG9P=mW)o>F0uH-!CFC*=_AF_3+%)_vOLs^?CK=qr58VT-cQU7dT`! z^MxHVSgcdq_bycG$JX>q44m#>8EMm&0y`@UO(5*-ygH;?po4)~B9j+^NvjQl$7UyR zn+J6(`rrYV@EEtF;)p4uxWi53=pbTm5IoOLd38+t9kDi}ASWQbAR|svm|`N%nt$W{ z{qsi97eU;?qu0(8RD$;CK`r0M%C?Tzk~A%Q{sooVIUNQ2ivZKcj8KDAAsH+x(D(e zvZT+#Ns@>JJCEVM z?Td__CO{E%E7IM^8zi#T-VAI}rmV!qX6L(~1{( zTig7vTT)d$&b&ZUSDjeDfdQ?0#S--bTAyp2eJNk+p$CpHT?`j><5 zef{^wMrRX;G75Qt^4xSC_7PV%on>@Z%#F$vy2Pzp66)<(T$IzwPO(9#E9{fC<%>IL&hl zOly3#TU)(lTbS2aZS%P~L0!CRXmIplfk|B@EsW4&ppQ^}7LZnq`tnq{1=uNo^EUUk zTY+7#C7}zzo_YYJ1!b;7^2`80$jfq$AKbH!j(WIyI~`QD5R0T_$avDkhbSh+o}FJ= ztf2flI0$@4PoXAcI7B|K$c$3iT4i#uV}4To{y7|K#Brb{S@*tniU&P~hRV9thlR5Y zv@!Er@u#>S9qL|px$exDH2*@={X>@4s7(1`1N!t!gVc5qV&P!B%iEhkXQfb^oia9=u%tz9h-LqXwha{ zJ1Rb=TZK@0SCF!y=JQyq^lCV(k2B*so7-L{=dDs)R~O!h^ynPnGFF)e`}T^_o4HS= zQ7Tef6PVoJ7=z(EV`TGi3rh0&>aNy!e(k6|_p3Fy=$o+L#kZR_*l!R(!E!SE-sb5B zI&(0+KR}8wWrfQ=hpcBlhpgSgEbi@pHaXP06}hQA5s98cr4l>8ZMc4HLw%!D%L6in zEh9E-^f@3Z#@IO_=*D*qlHaBst=gP2@jf30XzyQ(nNt)Ezq>+(@jxhJa_^(pet)`c zflECI;PpBG9I8Qj;ZH3i%DeKP%}209%A^78DW+MMt?r12NTOOu>)NL5F?kRkyIzBkFU zFJ+vg0&ERP*G#qCw}dq0vruDh*iWFRpb~uj;#c-5WqNd`z`IiCZ*PLw1T-?gy9nO2z({b5jv%dGm+NZfBKml3q1?<8oHZM_<@%#cC z@1F;v&C8Jm=)ycU`)7gNpdhB{Q^j*{WofKROWw)A$1obAjEh4uAo1stdz zQ|1Ijbu=Hs+iRel4-~4*lct58M;%{CT(6;TxTl0_pyoxV$V8j5nCf>R!SwsX8~Mt% zL|T(!Gn&`cr93rR;<`9hS-{YlvulhamfLU3B;Rg-O+q^=xu+aO19FXk<)Wx8pFT&_d_>HbXwPFh+NZ$8TeLBZ4>O*w9inVA83nu`QjnO)U zx+kosbSoa@_pn2LfSr+Q_7#0M#3?gq}>d~(gSrB zO6SUgTgO?#S2v3ZkWUaV7U_8Y-72+hV&83lIriJQdEeH;NDkaKeK#b9YXUiKJ|eJ< z2otnSwtCtSlXAf#x&a?7Q6@^ z{j{BPds+w7eO)J0ZI->_YI#^For*!fUm31NGpmLDi?Vb&eL zT3=Aa^6E^TLCRQv>vm2S6+tvn%Ut<5z4l(2=g!K==1N&W5K&W|MD`EC2>x53iic>w zQLx`vY!4Vr2CGRkSNRWm=ouK$ClAMa(lvB<13$;nGrz3g=x>eh26eo!uA9FymB9g< z0%=`GmSthIn5^nU!wMMwxgw!U+->v&8#A1i0ua1eda2A&K( zkaqE=P6ua;RAO;>j6RW%rBNaPJg20ZBra7=vMdR*Asbu4mZ04^ajgMp;FsvAk5jFv z;n`@zGSsC~a4$xS570(NH{ZOTl#Tif1H$HknsMmuEnwqrDwpp-DZ&wQMd+{4r8nn* z{G_B+PVCEtzzgNXYU!8i756oVOAc4{^7HV7e5&&ovE82R4aTRi`r>BtmHy4{L1}S+ zTJrxKmw263b4q@}D#73nB*oon3MtbMi_!m*1^Z$W$4M53@0W!ZP)YIY^6$sA#%SZl zaAQrMo}h@Vj5r-8+90OO0jnn64NgsF%KPN#go!AtQ#AKJ`M zF-2i)zC?+EJKF_25P2#Djz85v>q`cq_3VN*bZb5WhVnysHdki(>eiFtHCz1>PqIR9 z41LX+d|4Z4RnTURCu_s`2rkV~-e4=}~`?q!K+p z70#Zosmpv3A^o#plp3#cE2~dG#X2c3@f%|YPjqom^Um-??|K884`%(;jm}%hU|{>q z{W@3%Ak3%q&JFG??_x0=mT~pw%lk$ibuTRT35sW@UO**EuIZLa?1>p;2jTnM(w5U% zFZWQo{qJ~u1bbDbTT6GrT+|8kyCDk<7;MGrJl@%#=)n9?n?3Pth6neRdyX9PKn?*$ z-zEGaloWetl(-sbP)!$tvJz@prw?z^<^}e=U3|)d;mRij zH*3l13fn3=qVL@roTN84eQT@J=oRWG*o)G<%x8Fa&h`NUGn12}Oy?A4Xb7pv?)X8D z6b42uk#>yey5`TdSJy9M*IwE_yTZ}D^1ABf8BH{sduLU{x)D}5o>f+R_nyEhrE-l_ zB3+HRRWQ zE`|#wa&LSjO5iX~w05D5&Tik$f>2h14PcEm#Jd`|CT~c>u@azFF^8V{!pa{bqq%;% z+viK?vCnf7PZ2J@kIdk}@>&!!uV2Eh1%~9+-@2$dSn(hg*pu9RU~1d-tp5AaxWgp6F#WCCYio3h(D;<% zIiOhP;>dOEn*{Oe=Wyb4&aKUn()W9&s_L&{#VXgkc_zN8Oc6h*ADdr1NBD6^K9fkE zlz=?8;%`8b?Zm~|EWxKumM-yMvZ_jUvR*&LrTOGlV}vINWQBJKCb0i8u<-p73-2iH z44E|pt?E)4b|o%ynU8m%MB-^}m~BUW2ov(O6~2u|O}WVh0BBSQjIdO=F*Yqmm;&`u zC#jiSYghn15va1#KjR_rkMJC{(_Wc!5}%3OxlZC&-IXUVA6RAw2DrPOusG7uER1L9R1C6D<)-T(GogmS_(sAF1^NqK}sr0*^bon_jt zdIyg!?tF8X3^l4Cy(3PU3i@dAf3$Xq7QvvoXn;()8qci%OkS_}cHAvhI(sR_1RF_o{d&W`Fv!L>S702WtaJ0E}y-sXKJ&_bnG@*fTVN(v>3 z62Q+5SDK(>5dp$U>Le~>?35w_5d_%-XrHm{t9*-$cBzU{nKR2*{S>kUUNBazC+Vm> zY7Jc{Zq1RmqEVQucWwXlbwX1tth}T3*F&b~79|BAGl5dImQR=OAYSQIkpHyn+`1zl zI$4k%ImmFg%s-3ujQh0nr9J2KI4xLGp{2q_LcS5N<;GTIJiBTIQM~a}z#|doYqw+{ zX^_~!WOzll-&Oz)`7{T08+ky+Ul}8lVLk?=K#ofi0om|V1amF-!|~ylpMgD@>)8>j z*1zW;+`A)RoV*3z&0I-Y@CR5QC>#A8;t4T-jO?(NXH(*ieLXi9mJs7K0i{N5CaEEe zgqe`^CeRPnE3^c-ZsG70mjjF^hx{lQ-5eLaQrq}@??w&DWp*~&opa)_2`mdlXvuF!*Wz#TYI}f&`dK}WSfXR6t3TJb$Ei#LEWhh zo5Qa|UCNg-W|2&9aSX1__pS>#4*!T-GshMyrxHbP!vGJD-8M5mSdb{eNs*vF4pquC zVe~T2w>BFdo!yt)NG4X7G=3}inrF!XoK6@OHl!gu(Rp3kzFyXFhMg3bXFA<}CQJ1Kg+1X*O* zUn2EFO1}L#>f)j$oSF16*V4Bblx7DTth#ulbc&yk!3$m#NweR6ym=$Df8=5{2i*+2 zJXENP4cht5oh`@IFXYm65bEa*>31@ukXlUgNgW3~sxUiXVEbIU2PQNMEHM_FKSe9u%S1Nx4LYs?u)$W#aKvoU5wNFKG^-bWOx1Awuq7H5e2NKyQ zvmP=nI6u0!_W|uUo#~Yeth$c%nWqYT#0HcMJi) zUj^W~!>|0Dr-9##2GDj#57XxD-uUIt5HTjjlm|Anzu~;Y+Cg|-O=tD=YMtZoqi=nj z?UR?_HofoL#o-PCRksvLX3HTj-c;5cgtWpmNAmt>VfYhSdch;t@ChD~gPP#KAOyAR#rlD*Fp!oA&KX2wKw9__~^ZEtC zXCG&y=6k8;_0NU`{-o}-9DX2Dn`M^f0?oX>W@)-l^4~~hNK&P#=SHQ+6!t!ByoWGV z)WAo1k?Lbe$F7Bg_JXC{svs;lRlrjENHxd0`}dwz=p#ZvZ&Tetm+00Txe4lD#oNoz zx_2^KKNa+{oz%3zx(Poor3HOh3&S6&d!K|fQo31(@@8H)et@szxkJFNrhG!&?(WWF2bcR7DsSV|C5ca_0L=YT& zXY>Qlu+7O`*L}YB;!CC9=4PC&?+L}^s~@0~ZS=pmF)-Y$Pq^6-i zp7jrqh$E~8+6{gG(7I-4l`i`Jq0+lH?MFG8-vb*&4}zjCf6LZWx{1%fyg5Td8bS8{=9!ZIHf0W6Mobf?eqa*IaVsrec4z)w`qHywuu76hN%d9x7i_qyFj3UpPmPB00``8;_jk?+3uR-;rKHNgED*>m;UtJE zLdh4GI;&F=)o<(>5z@(*wHd1^@1ojqDS8Bh(eyJ zCXX@IgjxC$5j8IlQ;D-4Sg$<>Mm1~Fj2buh5xouYlbUw)KEZEz?QL@M0r_Zm(ZqI$xoQL(q_`Unh@jR4cs1%W}{iPu<;?hr?8XB;+D zQjfYJnG@@7AwynHYWpE#NhBm<#h$x&^mP8VO*g-uC!F*M@o1NC@Yg2DLBUzRA4@7~ zmD({vLOt-=SN-r4?St0?-?p-_RW)u^h7vOmXa0Ph>-8xvX2nJe|B6L==BVS&Qc-Q{ z^VZD7>U&~Ry|If2VC+M8Ug?iwATU~S2e5AyyIfsyPe&xFok!D8@T(hB9kBRaOrsEv zB%7NUlFEAWdLQDL4dumD9bqlqd5rrpb(BAyv#%YO|E?)^@I8@))T`JvO#2+C#m2rp zJlY#I>mIxEOhX%|b@J0e#Fl2TNf-VNCNPU%FRw#)ClE!J0W(4@*!#MQ`rX6E2!#4qJe=#BMR{@%ReDhtn5fP%niA2qkLXkH-LsO9Wp z)wr#nKH%~#=G-{;Z^L`Up`v(JuGOIZ;0jVD93Y_r=e+9SQh4SH@r2>UDa1F0rlJ$SZ2MJwvur&aN)-7Ei9;21 zT-fG_#|H|}YO3jMz~{})IjO`DE3BgJBN5AK6;g)HT;5wecWc2Lh|RpsQaF_)T4u83 zWY?nNRsOoU+5kwvcEJrRsm)<2M(nKG23yZwc+zdcdYnF+ zvQ3#BI|Jb%%dBoiM1NOdaVacFiJ+9f4qkgTZZ1LMKUVDIW)xh?RHz{yu zR3CG9azWk>b7WVZ+5VKRMTNs%3BLT53g{K<3}*b&Po+b;Z4{6qGBFHYLOZ1p9ZQj} zTdw{p1l^w4DL?5v3r{MlJD?Fj?iAI}H@p<+TO2Zm2vjbL*gX>Tu@f>So4~;Ry5gcL z7A9b9RN?(i=F^W2?$bVdtU>bRT;3$rOKQG1!(c%ykX0j`^8;RAA!}c7PXmLed3`Vy z4#`c7CwKLwlVhWRmqLgi#akO#V=ZPE#R*2ZqFedHj;*%|!OyHOA|G}{(c#4TirKG- zVd^)`Ed_9oEd@AyuwTS49|~N1>^oE)W|=vg5bgAK3!_XxB_$gm0#hbqI!3- zjONY}{%*Rq2NTQJRlM*c7JnGT{tr`E9uMW({+&*HCqak|o+Yy9!3d$U%#1brJoY6-W6zNF_f+Toz3;YM7c_ z)ctuntO|ehMGb zo#J2|e?}-YJOf!3BTI`N){?8hUy8n&H(LmegW^!wX%iZB$F#QGHBIz;cPdXgwn}Xl zVOouWc`T8?Bd)1+Wl}Ywfo&Z;A(Up%?aAfwOXVTai;7`foi~zMhI5#5aT@}js|MpP zR%JBRpzh0lay!Y0qif~ZT#-iM+D%&+xk0@Evy!W53^fiR8m-1S+%4wSp0&_( zN)&4DJY=_j4Cu%jU7+&?}MtL#^QPT-%!>Awgw2f?4( zBYn6w`%gt^&S7TN_W?{35A;wh$0{{KN0bxl+UIghR2qS zyyh2vgUv6C8jm{qfq8}Cx%oj_;MdJhK=-O{J*?uoL{@|>3KB&yPk_tP#X=%|)P^S% z3mwwkskzhERhgJ##OS@4a0i+mSjB~c^2}8U~papw$ zSpDl?0pbz3yl^^S#9w*p;!4@}mYF8u0nab>pzlV{4WN4)WB`#koX-ypPqKCy@s-S; z53TNaNeJ!>I$#MI*w%~IAQF@XHmLWf`+OpjG8e0AzwiVko6cY;x&17Jd^N`1{OMe; z1)~ky8r`bS`VMILwMM2YFP~Lfh=UxXKIt1mQfEv(9alz_a z2z^G@MSf!#*RxblXwBXHEn)Bi%FyafHYXIXCq{eb_SU-ndiy6Ypp?uRAclw~-edz7 zz`#=e#^HjF(yVM~mP$ulX&wXXmcBtDrM>t2YeJumh5C=Z_UG-i8m9~(6$)EkCi4W8 zDOUU8-hOg5lL6fa`O+#w0vS5t9cM6q{_`RN`hmQh%@MG@Rpv$Hbv=G$rQMo!S^R7a&oiYoXT0ix-n>Ls&BYh^C=LA5}B zM@ZcQqrX4>87ny(`hYVSVd&>ijt?hqQkf8HNwxhkI6O*p^RX46B-o27TKqD>V}~>K zi>8KDVWw4OxiNJgG`Bi+nMSZNedvFz$F_56dyJd^(Bf>SR!V#^|Awtq8^c zxxMOB7L5dAc)1yWBQ~q-%cK_%f^A}Zd^*$F|By==LXgtZhUVE zkv^Ug-q8&OqP&_1={Vo@m+vqJ^el!t%_004Rxw)0 zQxF#n`4~9Mz~i}u46`dWboK?9F*xN^tZn%iCN-QvcalW5u0xy~)>0wot8P1!9jiND zKyz16eZa`~U3lD*OZ+4oupUx*kb}Ynj8y+{x&`>}4pb?)287kDv@6u77A1sC3h#WM zf_(W;Z~l_?VtZ?*t*r#5%f4BGSv!wucW+F2ZMf>t#{9zQZJ=FJ@yiUC4rZP1Lk~XQ z7#CDpob!)gk96i%L1DW1B3g6fDr9b?;3UFDScoHjjI8LnUi%hGb&wW&m#LJPf^1U` zBW$_6y@_=l3JNlSBupC3bXU@?4p@mFTOrg^^%!nk+ZQc+=X^FiTieJY%>ro9si!tLogDN^S zo*bKLsVWMu5F@NL5H&<0b_hbN%GsaM2mv`mA#==bP7o=Hfy%l~EzB)x9FF}t_^x%D zVJnt+3AB(wK-_XMU?;SV8lf*8r6X2`NvxysOc&o&!#_tO%ZVYBRvS5|(=hUFBn_QU zsm3O;j2~0BE2R;eq=n;<6X763MnaMVYIi$WjNFYNJUK{ODX*qz4nZVdTQ{+wTuk84 zMz@UF7&W1Ru>@B4v0nT6){gZXN&?UfttnQkLZhdfD3zahScMBBI~rE}Tt_oD8|%{( zs~S1|_s;_1m&E==R1L%=;M{;Uh?>hDXN@6Js^2JsB7jrrx{3JAn6Ui%srUkJbE>_N z5K3`vM^sA=hb>WUcMvsiw0~nIVh@AJEf^RBUFvO=3%QB2oOn7V={+xbNFd4oDdciF zLLhDOzDxE;eqDX?j(%SilL(<5!NzX0_t*hMJn?y(khpBR5nhIyA-XN~@momD+|z7( z)!=Mwu;m`%C_cty4?Wg$H5cNS2_8vjV-D~J3{<7PqhE$5z;9D!fwBL5$+cYijyLkH z4n8|$5+MZ~aciO2Wh-cZj8NyPdU8h=b6+Y&tC?h)x-?fiz$MCp=>in z&ASj&&R!ouEDbr#8vRFjJ`$3eid#I$d%y@xRf_f#EKLQ+qB?h zU~VEZH)=aMQID+Zjlsz%DU@UN!SbJqJ&s6X#J&%iW1zb0Xf8zx4^{qOJFboA`CN26 zV0p^V&%JZzv{0>IMR|F)4x6t5Ekg0nR0eCBWM;=Q8qseoM2eKbe4s1s5xF!xODqfh z)jC05P1u3!5uk++w|!>UGr8Dt1r*pxC9C2usM(0ZU)Q;MBjd;2A%prQR_~&z??Fnh zN;R6pa4tXB3=33<5_UDZ5M~tZAX1Ox>#U;z`wCzTN5sH$z_ZIOmL$FVSt{>aZsqQg zoV|A(0qtsyhHSGgJb8SNDa4wBGUJWWj>nyV`CxnPt!iCX3O7D)|FWuI&wGg=jB_SJ z3_~bC++H`bK^yV3UKM?u(}!+BX+88FPy4FFaXFvUAaIZH8Nu56Rd+>!YTgKL!;AsI5^S=y}jHJ0!5-6 z94*_9Ix6q#2uB0(MA1%)wiihYhoMlU5fI=27P_++sb4y3>}A%HG8!GH55-Ftn>6Y+{LC)o={5HPwn$wTY#DR@xjK z>$_IkZop;%e)HpKMg$Rub5U~y`XEH3%OGzXW>b+FSJ$C|Z`RA;v`#-&rDA}J^Ho;|7;F`o@v!mMJt*OiE*dsWqBrIp z;^1(vP3T&Hh^y&S@924FDAn6Z1LzjM2nSeN9ZjN)7!9#AnH(Haw!MrfL0?@lwDI^y z)YfpQ8?!PcT+si;)HV(xufg@BO)&0y?9NN(;1`UFo!I7?S&Y~4SN_(|)rrzTdo@?U z1;}`s=t`s$pf^^3PQ;0w%t$NMFp6N^c#OtBLnC3>O$0&AZ{7Qr4mAfhR!55qeoS|1Py}H@ zR9qrt@lF5Aw|^($*j9)idUDRGsP=+$7ya{5M;w`s(2Gphpk3uxV-(zoONP$(Wz`h zxo4R8$2GCb1 zg7JRcma8{GLszdV?Y`vt5LQmGun_c2_isL3-sA}56C4~<{_^eWg?T#2W2l-^#OCNd zFUe?zRqSo{6HnkOrF9bVn!Aj6M;DKb>o}}peZfu@n>|jm-;1IIqJ-dacWs)-AmJhQ=%x@48UwH77~5)N0rDF{o=m)gb~ zulSAEII*jo@xZYjM(iU9?VuP9)~6yJ9sEUzv}tJY+N3X6C{fNThu)jc zGAiG#BKQc{HQioVtcJdN)X7aDLLOLh+{5uf_kBY7iM7DifIBje^5wy!ISj~-8gm=b z_6r>tpl|lkn!QnvJDa8F3%owB*Ms8v${l zDyf~g0%n~zpG5}<=T?ggSu##63a04@yZ=l&m{ij?)j-SlSl6sN_!HX2*WVe7R@_p> zz~>fJH2i*P{)&+@)+7-I&*HbPrsfDdc%v#fc29s)*k|G_++q|IFevqlki0@I7i z*D)L%Df8Lm>^#}z>UQfW`TyASmN(KK|6^Xy5kl2LL$;h*3_Vp5bdp?^2h+#u)S<7I zgy6v5|FHM!Nv^-jFSZDDMV>M$pM;15KV_55WDyco?;yFC=G8P+&32Dv`;edOpo9|B zSuQHJ5o)!tiElG8kc#Q1NFp)Z|2}r5=gUX+OTb9Y)vLvmh|{n``{6;4$bzjHSl>I# zr{(rdcthpHuAQ22K5TZUPUoPFTy#OQNLIb9SPrh*FY{`Gqi1U`5t4Bv5BA%a33lOp zu2V2P7#N9)5j2h#^pmaeQ>^758nfHSv3R#az%n^-<5FVY7OMFy3*>|o>-0kI zKpo97h7;byuzJC%GGwT7Gz^fB7OeDRRlAFohmH`JR##zAzSn87LK_d}{~h&K9f$R` zp;T?4e-!Hd9EF0>%BT&r=462_6O6G_+2>K4HVn{Zn(Ws-L4$8Hfi?SCG*J2DW&1N3 z2m$0%8jgXHy=9>)1_zrswNa>iSVj1*l^;K8l>#LXy1f1eRV-Wn_~kv8@_eqI+EG{Q zF~q1l0YB!+{9CbcXpC%r*H}gfE)o;>PE0Aa3(qw}Z`*4wr%9O|yUw3cjJ9>srJ8`R zpE$A{EemiIhG-0w0f@oNS(6hbpWpiC_3X-@jTYR2g5C#;txV`|AL%VG9<@EKBe=VMhpS-K+X-3xm z+R=GEs{N-)MAk>rWe{D0HYhM7w_ZwI5Q6+&6HXl>sP)7v^2#iAF=)4Y6@L;HcHF@i zLo$uctaJ4=AOf$gzQ6>U=3h3`h8OA<;@th^IMx%Z-G>~$-OjjxvUz)v3L!Cfw$2(0fqGwu-?nT@vU7#5Br%JCb?+=$0X!aDZU`c@ki zf21Ku9bb-EjK)o*Kn$^^ci3MU>VkRk_a>B@Fz)w6+R>oz50i*lbk+k3$=$1^<1J-- zb`l~i#8(ZSd1q%T`0F|U9GIeAIH;J!H>N=yQkKyOg6FZsxM3Yt>N$(TVgs%n_GTh> zlL%Ass;RHRp2W;XZ?L=zamsPA8Dg|#u%bNHXTD5`^W)f1;$js3lBBb5=-T*`9n79K3uNY?91_Q>f0}Y zk2dItL>V{?mLq1K#EQ6j6g$%IcUunYh3NcT)88u52IJBR@1gR%txW?gI|q0gD0}W6kNCkXzVCU9h{=Gq|1zL@xD7$@HIsqc ziBqb1+<{ch^P6Qztdmt{*I!S*x!(DAJ>2Bk3Wtdv`R5W5U=m-Nc*4HK^x_2E03m!o zT$0xiY}gYFXg{#KtUn&NoW=@utDDVXj%9d*rftM87*l2Zx=D^9gzRU8Ea+%4Id4t` z1o&Y~+lbESp*iHSx_j$Ki=jdpx)wtQuS5t6A*&!Zcgbo(I2tM~vc&lv7Za(~G=_#v zS8nC)jw_)B@}Naj@^ap+jarYviP(O=(AHKFP{Rh6DOP2Y#h^K2Q~nelX7u`U2^s~? z%fd(0pdE)2;Mk4RDo{}ZX@PnVGp_HtclTN0&15y8n$ju_M#RMMUINgV_TfTCN|9k6 zZw`a{hOm=lSrMtJ8MRD3=Re4EZPG&d3> zSG$}Gn4>~bW<^Bx!M}?o6wP}`MNV3}bGRQ#E^y%D1I=X85rozxoPeK{u@;oso#&By zAss}jsnZ?rJz>*@c(h?3iW4x3D?NA>+^6UlEuUcKZ#9nSLO}9}ZB`E@s`_vp#=2@5 zAkD!{AoxGaR4m8tJ2NO2G31{ygy@WNMwlPjlKT0NBcN$xpbd3i>;qH3pfjzW%H!1x zdTT#Rr*kI;V(!|OG`3A;aH_~O6&g9MbdD*!uA42%2U1PW6JB>{$YD3RRj7dZ7woa3 zt;o*@e+kz6xJ!*SXYKy$-l&J%RUoUe68)Ymhz5+&HtLfI12dUR z?T9K1X{arlD+Kqu8v&WZgbo~St!sbk5EOxHL#EJ&2ghh>EXOk2G**Ln;Ko-~WP3+Q zQvhE(QKJ^3x=oJURChVLp)|jH@kx=6oweoP_P|0XLYd&3x5D-+`SXE-nwatd z^O_Isorq3pgbtpE(W~c)1g=Uy$eZ1FmC)j#jaO?ELL+skbH0=&pKmYY;jxC3py_s+8Ch7ueNBHd#P;*R^ydAE}z9kh^ zkTL6IU@(j6o07bnZ7kuW4Vk$`W2&+(GbU0Nm8Is~@rKkY9RNTXGbtCS5U;Yxjm?W< z6q@THkLD?nwR3Qs*rM6Vj4x3~Zb6LR=w{{tHY9XqA8ZH12hM%Z|2-+KAJU+*zE zwE@rM4U#2$|7EJPf3v!}Iw0`E=eHpz!S0c`^9-xeJt|1RXThybx1-(>V9*P1nTfP} zkx(+}${=<9irE85Sx)$_zFc;fi^u5{u&%vEXNnIfffQop4-F%>MZWmJLdW}vWrQ;o z5SP7S#lH7Jf>5-!vpqJoP)0^ZYilbBV)A^LbUZuKf6C-)w#qM=ec0vH8M*pc>_K}DN^DzW zFTj88tmW4)X(vL})pagMJ@9B+`mCuTCZee~KXeK337P8Z?JbDHg<`q}g+o~|0fA2G z@d&6HTw#%UcQgSf_RL=!Zzi#&9}sElr%jFQrGFJR#u|t!<;Ao7YQ6@*xIrA&pf;qJ zcsrD$y!j~sU^Ha~ke@wMC64qORY7Qx2;Ot@lh z-`Y?T{I}ihiZ*^N*`Dw}7W%F0-Fv=g+WZ5dxF|64a`xNg#`)rUTe$fiFd(E+0IU(& zjOPb0p)V^C%rRPKPt5_M4tDy;rCb?Pa(*USijpj3J{EVPSx0>HmoL&aw9c#FdGUfdjwz`Z6&uiG zrED;Jr9K&xETj=?`1j^t;!lFQsc##nVp(OUX86(`as5<|cOPUO8Esln3uP`3%_9$} zPomS`L2_JN&7|XI8IjJY*mKcymqEV`jpkW5NhP<9bw#}kpRl0Cg-=%0FqTlob{3#% z=jt+yFVVKETG(*-)IF@5>#}(doJS5o8x+vB6?}zVY#l26(s1Ll9;$vX`@BG+2r?M0 zYLiEwHz}iw@u!bPu~t_-uM%)_OuE(UQaZOf)ZdXFLJ%*Z4PvU*`Ta^Ykap;=x(=5C zG5witx*7S)8EwVf#g#?dvWyRuJqQ;4aHA_y|0Qdhidb0_RCYt*Gx+e_N_g0B-fodm40XlF@Rh< znZ(Lzn!ojv96lfLNhm%`AeH%+y>WnEi+VZ^g8J0YmF6~Q+v8UB@^Ql%W7yT5yORc1 zs=fBtWIwr)C1+p^v#su?oyDbIJiP3Isw!3Chh;Eb<59^ zgQHeZ>uZT?2`3d@-1RZTk%h6zBRQ*+vWjy|>ra3)EWK$V==jO{;E}EGtl|M^*DJPE zBQMR33k8+6c+b%~}0clxan39Dn0je7o&X=ywIa*c?r7o1~lV%ghTg;&8%Ah?xZNgSsY_Vi82WU;2rK+i;k0H{=KrrIu~&chH= zX@@XBC92w`L1eCu4$`N!RA9o3ByZHbrEj|5ca5$b& z%(p+EmlN-oEzl%)L(DE9fx*4eqJem zU3v!lUY5wb7;f3gocX#W9gO}myTtUezl{l2Wj|L1w4ozJq2!^GY8pOI+K%MeN~H5d zv?|B?v&J7Y)Imd8$FdtuXh->5#21tyk%*zG`>T+3HP4#&%ciH{c)`3xG zTXQ)=O&d~+@MO30_uK69-+odf`e%J-`r!ZIyZibHRpn8ZJ z5}UJO6NusQ>BnRnl>k4bJfOWTHVQnKqs(v@p{$?R{=Us1tkj6@T(5q8x4^|3&MEaD zogV$D66WPEAKznrJ$3Syvr+fC*0rlu_~=&}Cw*En5Il4~c%rSnh$J^2;^Igb1m$vS z&YlC?Q|F4}8?(+O1bF2F4q;hCXCqoK4Rj(r9Y!b{Csuk){EuBX{M%gxIE(a%blCekl0XBP*HVTcRi71p< z+8s!1HB)MGE-Tmc3QW!P5~YMc)K}qy(-=ub|M8ST2X@_mWVVZrH0SynSa(pKbHIo)F*43GSft)#(`UBLnpQhq9y*8D$5|8gL`R|AYuC*s^uKvHT z91WDIHA$7`V@|`SVfOnNe!4?OQc z#$a`M-(Cd`9TxMz(FvPz1bb%cXN_gFcHh&vDfXR4;7Mb6swOeDcfI;WA+&-%@e1Lm z;Z6+)i#n6q5di%aQY(Pec65;PFF}6LB z@t?B*)d;x%OoyGloD`(rvUhE=%hZv8yiDgDnV!-#WE4V_yC#FUDc92!PTIZ~k(#Q^ z_XShc*xRLk3u|DJIiU>(ee5+-^!MArT^qhKuFUUvviL4O47=bhmAC=O$8AGgpRO%3 ziC`fxcl=UZf8o2H5gnQGb6WGPtIJ+j1C6It{yz=}?Z)_hw%eHTIRk4RG17panz1|PARoq061j7ACb0|J^7#u^T? zdBv7d=ET%}YU|6zpuJfzoP+}6q?>0unx@bRwboe=pgwGWg=PmSOf&$mPMk(yiuycgjEa`*&;K3lG#G;rcAMA%>^4Y-D~{x}C@ zBM8YywmjyGKSFI`YIZQmVNq{hh={fA@sD_3jk7co6HCGVbVKml~q!X|K>i@$>zJp4ks6;XM!&Vfq{Lm^kk9Nxl(s&yig zcpd0HHroEU83r|g{2D+c!Ezw+op*%#L`A(=lG-wYziHmXS3f4%2yX0bu51btd^f*Z zAzeE&MQcgrd@WNgA&`o-8O8X=22iI@6;3z*N5LG%l1f)SWmaWmn)s*+Veg3BjUv?V zVSb;})Vr8sMgF?mf6s1Sk$zOFr-M0WA8N~-)WAaw4#EV9r%p1^MoF=LPaZ8DxLcut z4t6>`;Kw5gwRWF(6Xgb+h5YYI5-woK)#tVmr5ZO&pzWd}4JosLp8FE(>-%pg%H_o74j`!;eV35TRVPWQ7sJCQ5$)dOOI*$AX^SO!0a7Tf6PkG zEUq@K-vbIe@fAnmR*Qr6qnzDImAO`~CMKC#t%#maCwiNo1LtJ*%ie3G#X+nhePEMI zxxQ6q+yqrp9CV_$ri9ujW3;P7U;l^gMH0X$n{2r(B~pnS_L zb1Y+4UXN+$l?@-m5^6&-(YXnX!e*g}eAMjM315XI)T58|8eE>iz4`(uaY_ePBDr+J z-^43h+d5rKOqqh5YNkFV$_bm%IL21 zqMOe%Y3?e7_1)E6R<2B*yG5or0!lJH*lzc7a2vt%TBcM}uzMoAj#P9T^KI4UpzX-u zn-)~WB_)!bT_k(YN9ZiZ&+8g?Z^2sKWyJLdfj%pfd73z4*vkn2d_8ivBmzN@Jpz^f zS#6O$JtDwn0GsJhE<&DjCyA6e7+M+&961_zSAx`pHST2DXn(Am9lX%#2&t_$g78&3 zz}#u@!l!-0>KfiRg`XUPW}p`O338=zwn(wrkcPURgvkv4v$=46I$f zupG#4ql&kB9QyQL{K18=za1X-jG7~Eg~mL}8&}95-`#z^GT3$5y?x~$wa*3a?7Lp! ze~2bx>f)MpbV)4KR2}KZ`PQQwfb>g$!m9T<^ zd5HN(y3Wgpj9I#2-+N0;0YT1prFhu>!5p;n>Pr&U!)KSoRRU}0WD!RAC%80L(X{>r zgmM5>N5o31EgL56DLHpB{m6@Bmpx0eKj7}#(FW<49D+p{%|}p4HABnnxG&k;oGT-f z6*Y_F)+o2OtJ6vDzy8z=`|$==x7ta5mU&yo&(vW)cu+;Ge%U&m!gIYKWJte#(*LZa z{slJ2W87sxnJle_m-YPmdZ*?dX4-|E6&v)*=u5jB`C)Ejr3U}lnTV61HkiK`^KIUy ziTM3+i<(vaVYQl9w1qRP>ti*F=Qri_@!{bnwwbS3KRylO#M_W6o57Mh{@F!1?uSJW zT*A`NL%*{lnW+y8v^)flEB%2#9BOiV?`9-K<7kIFLsdoJDAJJOyC`g)hrXeXx8B(F zt=z?Ri-D=>aza|>!-v5 zF*_201*$VK7|U^|vbS6(4n5tzCqu0-rjd)KNd+i8CkB2-5+JIwbpn~?kvLPrZ8d74 zGF3`yk7U(+58~TW8Pwf5mRSk0cbvr=99EN)?c@`yTGud!ud<*$wvSD{lj>UGNs|`T z())eWiBa^JtpE=)bf6vHI{iS7k-L;myp9ssBaQko{(h&Hj&=$B*5HT6Y zdb|A6AE%rxgJPeNC%!Vn^!c$oSZ?QOLImGAy>QkOLb;R-g3IyBXHR)R!5*e3Y`%=p zKgj&#;Mq;YrCEz?|H1ZK?Wh90YMDdo4$ZwoG`l0=SFqW!ZccsjfxrZ5AY)AaT`Y3@OY$#MkLnW%~ zyiH)kDSmlCo;-9DjW17>?O>Lv*WsQj&z3osYe0|;tmCalH?M7n6=XfYqc-122jobY zJ8nI<#Ws)8O1HfKnWRmG#rdC#RPnu}R0)PeoW~}n6~t0Ys9-;Ko5L>=ly09K>O8>K z~_v` zO*>D!++3*T8Vc1-NwFGx^sf<6uv@F&y(V8{QO&30ChP5NB^UcS2v*4A=8%H$IV2kl z#iEyAt1GRZOhCV7hy0DqMPp`)%dkTT{gJ-U?`R2{T<*o+5-ySJX8D_4%A_tJ6yW!< z3ydC`6{^tc9=os{%2y};rowAy9ss(FDQ^<}or<}xmZMT-_t zGi#mZrQzfM>Rq6oA?if*5sNtBlb6@I8Y&O<%iDc#8|tW;8h^9)0K3aqkmr@k9&!Lh z6mFYoTk?~V;G21TGvl1v2Uh(D271XuElwnH$@Fue6Wr*4odOFH(a*f1A&eHqn^h0Z z(b4?k%`#_{OhKh8^(wFl^)`%blg(Nv#?;TNBqTcHsU8s`ZG4?Z>V&i3UUb>xrs>od z<<%E!C64`c9%1Qkb`K3nD;sC+VAUK|oj$C7I*l9hXo<6X`gSh7QVqScp|!%yo(#a9 z1g+0WKAKm<*Q&6N&2b+6gh2pOV%PfWj;*%ixQ+kF8cA8@{zN|eJ#HM(|QBU~70 zVTC0o6H0>8oRf}r@^y2TR=*6ZvV1*%>B**8pl`Ol3~By*72(T9`0p6-)2*!>oi1AJ zGk-!!02RkQPZeKhmK*iMxXj0yas7jF{wxfJ_JHg$+mXcb_>Rs+>zjzk6HP=3RWV%% zTdab}tX|hU`uUC21oT95d4r*V?zNZBN&Y7Q zFmD@Y=R7$F`kfVziXRv-)E@D32=jWF>$cFqQ#9(FJoO{`f8MTaiio~9IkUOmvXN-_ z$!n#e&hx_CGOf%~mi=||Z6>2}C*{=U7p9kxET;%*yDXYEyhNBqJVjI8O)$;3fa~>4 zLcX(ixSG@M;X8d9q}8m{Os)|Nx)o=qMuc@F78dRQ6w1iQ138?*0|$bLe}UX5ueXMH z*d9Ua81dU#SH;Yb!1vnMX?=r2S=Sp-NT54L#9p)c{Ho|kEyJ(qWHn*FxT>1I(0P4a9 zf6D73vcBFczk*EbOAhDJ)0cnc0>A(CB@C>5u>LT`ojOw<{NwNo$_=~KLipu5keI@eI>E%5GLMMys>1n0dV_s4(r3HnbgD@EX$&OgA44ks| ziBBM3dyT@)0X=hj!m=nXuu$l~q3o))Q%$^!u~WKtgb;c3KJl6V396v1^T-m91J0TE zN34-hYa{gyol8fVbMwmTJJJKL>$vZ0&d-Rm&zVQnCnA>m3sJ~r2Tw+L>Gw0*X8wan z$*K%XFo)(^yB;(J0g%ag>fBY;Se>=hiWu772*nh$@G@pOKY4&%t3fq`2 zJC)0usi61G>}$W7>Wfc0&i*rrlW!k0eMdX$m|`CrgzS~6*YUISH%%~CPKS|_S>rCS z`(z!0IrUgWLmRB;vYpn~jGaHkszk+vJKh!Kk$zMv#=-{%&M$rYr+AIP-7MrNrR0XE z0tPah{Vl97!`bOv(yeS9^|jjJ&#_-Zim6d}IcjqGA4A@k>nBsbluz4S_;;E7(@*!g zxFhdBu=PX>=Y183u=!hL%yYh;54L$Jk!e?a_sjag(uZ+oMdh)_xSV962j7bCnN(y) zO3Fp?>4^~}%Q5B_d&PRbl|_6*cQ+Txzu?Q!x_qzsY}KbWH=rUVXO7v_3Q9bNbdzcG^Cm%DEo>z1PM` z>s@)r@@2Uv_3#!O$s{M+y++9QI3kh0EIf}&zn^(TR!Lf=UoSz>hJ}L~`xt7fsY;i? zi&rclZSIY8rt`VL!VbEMn%1J=#rNO6_4j+ce+5B%_<|F4SuP?}CqI=|9qKQnWF z<-7M6RRuh?rhHQEqm$e!_Rj%joD)l_%!M^QZ84~@c!i2acQ?x1^Sr`x5~}Kpxh!XS z^=6QkoOA;#%aADd%}P2-Kn;b#=0!ws)@Hp1dYXo*9}enterVEpX?e)j{65tp@cqdv zb}FgY?Aq9cm9Q&b#@HsHI-Ay~y{ogmdI^uz9B-!{wWubLf zwLf=F72K|p?Gf@P9{jXpr4#l_zy9RrhGNmRl++2UbZKSIju+C?-D`?IGhD&;W_Ufp zbKu)#!3(GeeRnyd<#Ov- zvkTm|@b<$>rR%FfO~-sy^7BYvoy=u&WD+IRn9WlPf7#kNK>mey2JwYzrk?^_p<5igUs$FXepx~_l# z+<4F~`-2dJHbFC7&tBd0Ys2+t$9GG2ePOgiYgTu(`_+*U`{SEEJXEa*>H-5jVd0+B zWLmd^`=>`al5#PbXw1{+&6^t@-%Z}VrMD7h+d8SZa-q9Go2YD})7F!gSQF%dlDO_z z<&m%7GvI;Y3|7PAo5D-`mpyK6<4Q_L%*;09hw0a>ItNoHgmWKh6NrDiF2U1>{<{ej z80Y%6@VVrM^tI;uHi#l=;pLs3&c(OZ1u&&Bmhi_;rjcZN1Jpx3xB$I#aPH#UGUyww05xSR?$qfarR9QsS0eTmge+hM|5Bf0qt% z)WvV7j8^U_!TNza6^70#L{w$&sjUfDPH3Fs<-hzYa2!kHR>>J`LpM(Q8-cGAGf#b87EV^# zAK&T;_FqJ-kvd-rUKZ|X!`rLi+~O>;#kssVT=1)qloCN4Ds@{XYe%RrvW_A2cO%2>bJog zWN74BLkXM-IExJ~WIb6y_yfQJXvUC*ZMSv*b`q3R#jo2aDr`Q)h2nVow+alB{Y~w? zJ}PJ0{q8G`?mk^>|Kbr`7}=@VErId9f zj}Vg#@lt$HTRse(kJwa4m=EHXODLY6;?2E?+Ncx4QB7ITRu{2F^{vSGxq+=K39V`5 zAXq>5=V71M{*c*>{XR;~v98tedfrNLFWYIc$P3qox=QxH@|NgdI*c;cABhb1TcI1w zo|U|TWaD^LsikW%L61eyw@^M(`td_0f3ryn@DgTBGB}qJi-lEcoVb99SB~%pXMRjP zWEoOtSQ8yEyKKe88~sJ%R{Y+ZT*D*%$9e<5`u=xUeN@S(!`L;ss5ZEy(y6Ih^~kNa zCJkjXPW||6auzJ;Fa(ONR=N3g%8j8;`be`4_3vf=sZ99miNYHzy^q|q*jKcIn{sZE z&#k@~eGx0hxdT78fW!Xa`&$$%mFM-%Y6`vOHmNPtbaF{2>U8_1s!2O@Usd=1HZW^p zt=Ve{FKfv8Tl^Lo@!EmUj={d0D)1Xg(r)61Yw?E@<&j!iD6f1mM;rIk<{1b{hE03U z8FOdcut6U$zmAKD8|&I-vCUvja>_Y0zZ%se{HajwolfO5Hp#wQ5`wSq$5j8Nvn8>0 zKOqK`k^3a}qI;(Ab&1XG&F*f{();)EHjW*{`+!KxdU0}JXbtCeR?y1ZiHZr4*s%Pd z#wiLd2A5-SMoEKtrdVs(Ap6m=HXHZH)@?ql!HCbQ$;sPNmpUmMbBl1Y$Kf1Sy2s|M zNXhED-YqpF{uVx2f>RntWuCG!Db^Yp2!f#)(ed^X5-4mavF-UV2+y~aNMzoxD`Ea|QdH{NWRnwEQ& zT4wHC848*^HA@>N2|1EfkhH{!W=qq=Rk<}+=IdUdxXOj*-XJP&9H==!#r64qz14fT zF8<=;cb@ay&wZZXdG2#g8tFm8c(8uD8lZCOR4h7O+0^8*$3+Ac5l;iUJ1-e~{?r>D z@Z+wSf8r*8!@GaueJ!hQNKjC<>S;0-*665$S}QCUjVBix#Bm>AhJolr+>8Bn_BHsA zWw|EhIN{y4-Z6rTpMBEmavG-lRsH@7`)ah%_WJpqbLp3Tl!Tm=eGKW(zgrUGhT~BcLWI9KiNb=q@!c*YFe`+SrL3(}eE6jQO#kIxWfJ~Qk=GBWd zyZ1zTZNG3sJ^OuHVPm0=_oj>yb<)m4AbyqD6H#B-@oBC)Qk?W`#V}ZH6`))NK}8$x zpJ?^wp4WqWVcB7oMmo?otz25?N6m3O?L3)uZoSBAULlnJF6MilN}@FwF2Ofw!0g|o zHA&i5fPK{Nc&g24xDTA`_gFmq$6NWEJ(7c@c_k)Y+9>+E7XQ;pPuHf0;15Alx~X69 z3WCw_@4@i)ROM&EKVq&5lDGv)C98%8VURmtEXRU_{HRdJ{MAb_*jr61iuUkW*yHI4)}o`A3T4A(T{)?C#TTlLF94hn z!;bep+#+qQo$A&=TrRy24;k5g3P}1H zp!;_wCRdE6gzA(b&o~bM$OpTN0pC?Y<}pkMAn#jQi@Bnei#lPw(&mlv26u^<3Wvug zhOd!gA)xPNcs|mQ0vC_tiRGfB-HPKmv{U>ks|2sLqJg>;@^j@B&6FotGmK#crOR4Z zKV$gyPeJE_uh5gp$0GcRL1iig9%#TN=v5Ch%05MACD>|TjcO335Gb0ipV)&Lu9BEoLV-(@) z%FM*yFw(K%svj`D?LD8>(9 zE@)I0VjyN87UG4Fe&^GX#c|>AXNFvNb zz?)|HE+z~iEPe#0fAO-pA?0lh=~=pZO?>Afzi8ts_Z0X_dM*{DeZHFk&$e@JZc6#||{hVYo2_#164xnd4tx$JT-1}-cBqPEeHD#>-`4+6_KssD2FVG1_@MYB7B`4{qYT?p_2=||*O&p(% zs`x_S%~EIoskYlE-Q5$r(o;jZDZDqj`po%i%g69oPD0$m0J^RkYfV_jCZ1ki$9OL{ zNrrv6yUxa9Bvb*lysgXV&8X~n)4AkmzYgDwK+t65O&oYNf-QJ*Rb`Vuh zQsr0#80}RqQ#zG_2r@?7-hvBkP1|&9bfgt4=j!tiM2Ky6KMLJvBOalqkF@Q| zXq#e-v>IQIV!9ptGa#2B^gB8#(8lV`2rxCZV8nx;Xry0{v(0xz3@NMb<5^8L5fB$4 z+lJ17Xz*B3?oE2Zf3)B^M2Z0;nobYXVdS~HF|y<<03NiT;@i&G%P#LXPasZ50XqWS5O|R?Lt)q!Il31EU}x66Xm= z{D7j^<#(SD1o|7}t-HqNNpRbai;X4=#5AX~Rbt8vF{QAf8;Oz&Xnv05>{;LSI)nS^ zHo{B$%jZ0#1e=uXRzChQ>o_On4+w!b5UshBlTH)g^;3IYya%J#P%UG-f0g=unV7fw z3lH2wo8v71s6c9h!xij8-XcIY|`qGYv+4a-=cZC-Ymrt0~5zRwQkA`vIF$+x2 za9^jhMqv4~XssXCclOIm#SZmx`epsP_E^+rbNs=p&t|FLir3@g?`wP{=MBT)}VVLkS(ffV<- z2-#aFjY6IXRWy~(z3dQHQQ7fXvD%fsJK?c=KPE~xP5C|Vtg}Kz4vWp{YG!oRQ?Lv8 zzU>!J{_fUu#(IC>-rWOfPAhi~r(N_Rv||&GzI!zPB9Le=!lQi>|3<&RbaoYh(^@nb zIpSrG3dKm60SXx$`DeZ8(y^j@MZ1y*2KM*Z{L`4(1)Kh$B(GlC+kpA1>Cn(mb#NO> z__sV|FMVCi{5Ny>8Y8#KmVAvf1YOx++l#1{iQyT`ao z36EPHnu9h;ryY7^LO&KBli8~vm( z@LLaYXUg}Paxq8=V+s&-jW`xclvie8#a9=yAIgOy0utKVAy*uS@%rtcN~=Lb7 zYmQjpnw_zM54&4qzf}td4D>aq)?z<3RePrxPIZ>E$T+b=3XSH1>;&z@_^$O7-j`4i z6xz=E6nc5__T}zA=wriJPS((OUsj}JOe~~)@=_uORf=lcM;^lJIvgN7zQ8aMPx6ZD z!v%6nK5w2Igi`3Yy%PHWUi{OYwXm5b%XVbP-5-=({d`U@twUR9ABx@!F?^UC!KEJ9 zkr1P46Xb1ehah0*s+k2l7{%ExLgxeg9;$UBAJ+Y;C9JqF{sO3%HMNan;%WDRCa1^p zBJGeWME#|c2x)u1LAXdc*m4V1XBrS4mgCD+TJmqJc0wN;zGMyVQfeL?AMl`1n&fEG zym(zpB2L!*71N0wdqmH+al+E}nCZJu=j`kb+pcHbiU>?mc9&?I%9#Sz15 z%k0!8O@6!Ra!cWJ7qt5GB38eT(=15*osC5Ia1?!eDW}_^e;NbdYT4N}uaoE-pN1Gs zAfHwuL&tU$<$eLRn>mBm+?kB%&~lwr`;q9AeeqMp0Krw~Fhlqm`a#>*JOnx>p*r8e z4te+D<+ReJHDA_sKvJpLql;&0GmC=!*l*Q|?;qIw#Lr$jXr)h9E6%bpT3+PVF~78A@1qo9&j3rq9O>}^ z9`!f=rmQ~2@0$djZH+(q5MKjPiwlQs14D*ix}lZE2P{cLdwfTRn*zZK@mZYpO;zp4 z7n-1h#fn!%HsP2on{tXfK{4Ba!zlh`EaIlxio~{q!aq0T4Zm7ssez8?)ybYyKQ#Kg zH{E-N$jy*5i5oQbH2jpB_umS4*bdG@4(HiG$X5(}EYnzIhN9%7dxJGQ9^QHttM1Qo zD?(&}xD!5DSfMdpxW(0o^$*=ELw>V=gfaufzYi4-r$(1Tez^^Umm zb@&|oP)K4>M-haiCPXOt`4f;jL&^H&6l`P6;dNRLU@iU+rafkt7Xxbiri<&5CINgD z>sTCWx8=F@@ceZmx&7~I_l!XFu0nTC20F^1aLhIt^u{6lDo#Uyl;jw3o8h{ddRx_I z1YZ=(ukPrh)euy^NZgB2)%HfJN(m|r+|w;->x9@amU2#cj5BTmFe?#I>;2o8PCx6& zKc%LQ`66E~X}ta8%84J_qlw4=c+Eex?i6~m*g4q0H`jB(8E!x6*Lb-3jqsFjD`bns zfIRv5T*TanoIX*>6pp!>Y{lynw1ZJy%fPwK1@2(Nf`-xhJQZyrG10@&$B)R*69gRj znGp~afA;}HpXMo&7Q%Bd!P4$1=clUWna`)%`%D&@rK=ZeUFmI6F5X*5+b3Lt zwq%bbENAbj8SdB0e*vm{?~bH@K)%4P(?-r1E>Qy!&p%v{=R+T-wa(`h? zPV!vY#%OFMxp1&X=ewQw>QGk$5UL>SHe20Z-&oVo6~-%C(B5lUuh-3P_^LP;ZvQ4e zw3<_%0p_XLC6@7b!x6ghL%0RTR~4@zti|OUN9%@s{k1@I>k+L;$feivBCrQP*>NA{ zOwn1o`YP`REp!C)k%D48fF>?A`HjwnPP;egW;##nw3fP8GDx!ywiTh8gPsNgB=iQ6 z+rh3@T$YxfM!O)#)guZJT*b(S(^DV5vuAA9S=E@eC$Z}Hn)J`Xwt%Hl!8F zMzH~2nW*1i*~^X&l8HaB>yXWrr5NF;?eOAwa5L)IaXnH8c{O&xfBt-Xt@02qkTNbKvEySOIEI^?~&sP1`mnMUFoyah1VCQl6d4#Hnf z25YMdhG2Ibo%4Ke?BKm>w*;tBZ@nHUAPSmv@;!e|D$#N~n)ry7!~P3kRAyp_$VOX| zw9%3@VjjUN^q1%%zUYgbS#pfYM`Ev^TUQoyYzZrl7GiE0Jf%F}!Nle#ce<~uq(rcr zp+#3kD@LRQxy6MJI>w6R@6m0YhIGz3C}kqr8Q7I}Eve=vuOzOAAjx(7;!tt{Y!m$wm({JG01Rww)zTOj`GHsGIKkp&^IVBAbs*90KdkV1uO9jh=3Mbn)--Zi75D?nfKXqX zb~>K_RIg8HJ_^7GAo`KX$I0YEfN1G4!7Z=`{PR~$t{X~%6cqq)tt%ek&*fbcE5Pp+ z5WpWzJCO|Y*R6NRvJguw?XZq4<{#2^!4I>xvwZ~|+~iT+n7(sCWDnTk-H&SkMg9iP z?!l_D{7hfQS7forYx~{!=6*vSGf2RKcu&>IE#EN zZ_C8GY;u90D?<%eW!IQN5arUx5{e_jq>co{UXzaZLp8udoXl2RE3rQ0Wv3~>=GRg# z+m`bx9e6BXlJygcU2N!&yK1L?=S0!F72f0dlcqf^iM#`%8*QU;zSPsT+UUQo%|E}O zYWwi23HaID-WwJ6cH&ZQKyN~?-xKNh9&UN-0h23xs!fgZ&%2^{Pd1NfTo)~AUu(I- zJlUeyO%mVw)@4uBHJIxHk6S%r01j&{Fe4Wv{37a{MIeW~+z5~~<#0IWSw{lWSSG9Y zKMaJN3-jgPrhkP!mB?27mAF7qXDZLnT4RNDv8vUSsFeFaAtGG=noSjXF)=1GO-wS(KRpzAn{q zjB41h2KNCEAuEout*RH${KpbzlB$|V!nd-L(~zzae;^@OHI;3TzN(%1R?)a*=?FM# z>4HZ==9^qty3RXJOjeq~F@t)FZ0C(F%Y< z>=sFf-4T4B-vAiuDf)={v6~+-NgAM%j)9m310cc`fcW+OEMld#V5k zm*>u6L4~P@P|V5eW(YrCcCWC0xUzRoFSh){PG=60P5r$hp+r-AeCN$am^^6soFP)Q z{pc31s_2yhaANrVhL4k{MmzRT8WA>Q(SO=MpyR?BE6@tK#iK#fS-Rz-OTB(bV^et0 zRzD+^9ezBSH(vPVjo8!Wx=$%k?4j*jGBfBAO_p4bGSAVMt1{)BZV#^}@<;Nq-9K6w zW&tdCeRSCD75cJdaXidf?&Fna$$p5~c&KrP{<>F!?$bZaP?HOq$R3ra7E7&lpGA#V zyxEHz4!!YC{nfi*w&bM!oj7))F>IX-hGV)eK1O>Lmx4m9uCl&JIxp_}ZFCv#!vf=w zO1B;-N$)lm;Jly@b<0uO9iEfdpImyPlcH;Ft3?025r0(YKY)kLWK3RBvFZ;8XVVmi^Sk0&Hw_r*Ps1pooE7x{^j@R0+itRxMLcA1E zAxaqGQrbfGxg9@}BzJ)#G`Q;1%w|pgt+{+{{>aj|;BcIVB%<6QGRjD>#cJO4>84T+@r3Y{HX zkcz}0QA$Gm%3zyVHv#21OgL*pl-Iv80=~Cg{Ei4$a(~x!adH|HJBhvEM6jQfz)kdw zb5zQd9f#-`yKS0QJ0Dv-@N*=Eb_Et)c`|!pPFr8xbCy1i1iJ{doEw^^|KjVRdxIt#oqHr5*D;tK0pg>!k10JK+H6kwq)XTQmD{68G-O%ws8W4OeO($u-6CGT8^d5}67MJ6p-!|OaDedVW zOu;}=Br%IMs5~=2Qz;#qp3aJhM-$T~$b>Sxkn>Zb8Tqh4)P++nAY;+u1kDl@J$?Sz z<3VTdSr`Xs>sf}YtejnjpF>vl7XF0Qs!82KG3MA)-0OVSUy;Nvis%$apJzH}cHFM9 z9`MN8dmyTh(qD%lSPXo+-+&7=_xB$4i5GR`;1z%dkj%FlehI4_u?sYzm#zJbaWjWm zFO}m|e9eiQTc+GHl7Es96O+6=@MDpUODDQ>XN^f~`7Sv}}KdvGBmrn1w znW`rhZSz9a$d%O}PgQ^Y$whV02A`nW>d(Mz=E6-1_l`$8hn}9*6U6y6$r|{JkfA5Lu zi@$3suI^>HC|dc(*wp8-gzBPDyOPv%Tb8UOUl!$UCA_#BWgHviNcu>A@8B^oe^dQF zD0vRlti~1MYcPQ0{+FeG1eOO+B>qE?7tr&pyvIFrICP*MJ(X<5sUW@FI5dR7?35d~ z_CmsZJg+~H#ZphLhY{JOl4Sz*WNYt>CD-5aT1Q7NTYbZ>Dhj8!0{XgJoP$>G9{NQZ zTa`0ZXQm0AEQ{u4BKrgNKzGsi>C_xEir-GFM|5r=@8=uZ`d^`89H!p8nn%TRMNN(7 zUOSA_5igPx20a>=0|P{U{g)8*5~vf3TO;2=^FoW!RXbDoA}igZ4~ab(wU1xOusD7A zcX~*|H3}Q)vKb&xR;sE+`uWYFWv8>rewBs_RfiWm)giw|6-o_c$_Wrt4i)3un2J-S z^DPhJTo%$MbQc3d-}fS|tNj@E2L2UDR3z~7?rkT4DAdi!2Og=cuF9{@7VR+^8QuCd za_@H(o;TZZ*Gpbl1JBh!=ul~Mzz(M@tx0yZ0JgJf=POnLLO+o373sEDv5)femv5Q! zLGAd=Ei5Qo86AGV`d#XJIm@?y0ZMrZ8+rX0^ayfXUO*BDZBX04v{W@AIB{JEw#aZTp!ltnrdnIj5wmm9kO|b zC8Os@(ERH%*76W_FqK8>G!E-aaO6h{?rjjdbtZHs5h`zEuSBCvBuPG&t>IiH(k`T zY3h02k6%{SG%OS@)ueP(r|f+J!I!WT*!3H(lJXRrz@cB+h}nuWlg6I8+o>>FiY$29 z6JEwMc42YT(rORw5)1p+R(Ah`d)2^-OKr)WnBJ(%!y=nJWUwc#z4;e;CXdO_RGRx6 z`S?@QUCM6AR7`x<$@|45u%{Um4(Yif9mXhfA`japEXJSlB33K zg>YsY{`_^11jOWy*y_*S0T<^KrK-@IDc6}GfldDQg3m0Je`+>HdN*|Qu04G5Kcs_~ A$N&HU literal 0 HcmV?d00001