diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e7023..1d29f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,30 @@ jobs: - name: Mypy run: uv run mypy py_src/taskito/ tests/python/ --no-incremental + dashboard-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + run: cd dashboard && npm ci + + - name: Biome lint + format check + run: cd dashboard && npx biome ci src/ + + - name: TypeScript check + run: cd dashboard && npx tsc --noEmit + + - name: Build check + run: cd dashboard && npx vite build + rust-test: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 784423d..931ca15 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,8 @@ Thumbs.db htmlcov/ junk/ +# Dashboard +node_modules/ + # Docs site/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a91998b..3a02225 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,3 +35,17 @@ repos: language: system types: [python] pass_filenames: false + + - id: dashboard-lint + name: dashboard lint (biome) + entry: bash -c 'cd dashboard && npx biome ci src/' + language: system + files: ^dashboard/src/ + pass_filenames: false + + - id: dashboard-typecheck + name: dashboard typecheck + entry: bash -c 'cd dashboard && npx tsc --noEmit' + language: system + files: ^dashboard/src/ + pass_filenames: false diff --git a/dashboard/biome.json b/dashboard/biome.json new file mode 100644 index 0000000..a62ddd9 --- /dev/null +++ b/dashboard/biome.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off" + }, + "complexity": { + "noForEach": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "css": { + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + }, + "files": { + "includes": ["src/**/*.ts", "src/**/*.tsx"] + } +} diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..9d3be91 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + taskito dashboard + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..397f00b --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2972 @@ +{ + "name": "taskito-dashboard", + "version": "0.10.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskito-dashboard", + "version": "0.10.0", + "dependencies": { + "@preact/signals": "^1.3.0", + "lucide-preact": "^0.577.0", + "preact": "^10.25.0", + "preact-router": "^4.1.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "@preact/preset-vite": "^2.9.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz", + "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.8", + "@biomejs/cli-darwin-x64": "2.4.8", + "@biomejs/cli-linux-arm64": "2.4.8", + "@biomejs/cli-linux-arm64-musl": "2.4.8", + "@biomejs/cli-linux-x64": "2.4.8", + "@biomejs/cli-linux-x64-musl": "2.4.8", + "@biomejs/cli-win32-arm64": "2.4.8", + "@biomejs/cli-win32-x64": "2.4.8" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz", + "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz", + "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz", + "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz", + "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz", + "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz", + "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz", + "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz", + "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@preact/signals": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.4.tgz", + "integrity": "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": "10.x" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz", + "integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-preact": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-0.577.0.tgz", + "integrity": "sha512-fCY59YQ2OMYWqE1V7k8HwfXyiBMHAfTI1roCOasdc+Cekya7BIObSJ/cil+tVMSbU6siv4uZlaz5twAGmkYqIQ==", + "license": "ISC", + "peerDependencies": { + "preact": "^10.27.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-router": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.2.tgz", + "integrity": "sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.2.tgz", + "integrity": "sha512-b8SxCi/gG7K298oJDcKOuZeU6gf6wIcCJAaEqUmmZXdjfuONlkyNyWZC3tEbN6QockRCNUd3it9eGTtpHGoYmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.11 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..4940673 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,32 @@ +{ + "name": "taskito-dashboard", + "private": true, + "version": "0.10.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && sed -i 's/vertical-align:middle;display:block/display:block/g' dist/index.html && cp dist/index.html ../py_src/taskito/templates/dashboard.html", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "biome check src/", + "lint:fix": "biome check --fix src/", + "format": "biome format --write src/", + "format:check": "biome format src/", + "ci": "biome ci src/ && tsc --noEmit && vite build" + }, + "dependencies": { + "@preact/signals": "^1.3.0", + "lucide-preact": "^0.577.0", + "preact": "^10.25.0", + "preact-router": "^4.1.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "@preact/preset-vite": "^2.9.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.0.0" + } +} diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts new file mode 100644 index 0000000..df6d3a7 --- /dev/null +++ b/dashboard/src/api/client.ts @@ -0,0 +1,24 @@ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +export async function api(path: string, signal?: AbortSignal): Promise { + const res = await fetch(path, { signal }); + if (!res.ok) { + throw new ApiError(res.status, `${res.status} ${res.statusText}`); + } + return res.json(); +} + +export async function apiPost(path: string, signal?: AbortSignal): Promise { + const res = await fetch(path, { method: "POST", signal }); + if (!res.ok) { + throw new ApiError(res.status, `${res.status} ${res.statusText}`); + } + return res.json(); +} diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts new file mode 100644 index 0000000..e65909c --- /dev/null +++ b/dashboard/src/api/types.ts @@ -0,0 +1,161 @@ +export type JobStatus = "pending" | "running" | "complete" | "failed" | "dead" | "cancelled"; + +export interface QueueStats { + pending: number; + running: number; + completed: number; + failed: number; + dead: number; + cancelled: number; +} + +export interface Job { + id: string; + task_name: string; + queue: string; + status: JobStatus; + priority: number; + progress: number | null; + retry_count: number; + max_retries: number; + created_at: number; + scheduled_at: number; + started_at: number | null; + completed_at: number | null; + timeout_ms: number; + error: string | null; + unique_key: string | null; + metadata: string | null; +} + +export interface JobError { + attempt: number; + error: string; + failed_at: number; +} + +export interface TaskLog { + job_id: string; + task_name: string; + level: string; + message: string; + extra: string | null; + logged_at: number; +} + +export interface ReplayEntry { + replay_job_id: string; + replayed_at: number; + original_error: string | null; + replay_error: string | null; +} + +export interface DagData { + nodes: DagNode[]; + edges: DagEdge[]; +} + +export interface DagNode { + id: string; + task_name: string; + status: JobStatus; +} + +export interface DagEdge { + from: string; + to: string; +} + +export interface DeadLetter { + id: string; + original_job_id: string; + task_name: string; + queue: string; + error: string | null; + retry_count: number; + failed_at: number; +} + +export type MetricsResponse = Record; + +export interface TaskMetrics { + count: number; + success_count: number; + failure_count: number; + avg_ms: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; + min_ms: number; + max_ms: number; +} + +export interface TimeseriesBucket { + timestamp: number; + count: number; + success: number; + failure: number; + avg_ms: number; +} + +export interface Worker { + worker_id: string; + queues: string; + last_heartbeat: number; + registered_at: number; + tags: string | null; +} + +export interface CircuitBreaker { + task_name: string; + state: "closed" | "open" | "half_open"; + failure_count: number; + threshold: number; + window_ms: number; + cooldown_ms: number; + last_failure_at: number | null; +} + +export interface ResourceStatus { + name: string; + scope: string; + health: string; + init_duration_ms: number; + recreations: number; + depends_on: string[]; + pool?: { + active: number; + idle: number; + size: number; + total_timeouts: number; + }; +} + +export type ProxyStats = Record< + string, + { + reconstructions: number; + avg_ms: number; + errors: number; + } +>; + +export type InterceptionStats = Record< + string, + { + count: number; + avg_ms: number; + } +>; + +export type QueueStatsMap = Record< + string, + { + pending: number; + running: number; + completed?: number; + failed?: number; + dead?: number; + cancelled?: number; + } +>; diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx new file mode 100644 index 0000000..cbe3ae6 --- /dev/null +++ b/dashboard/src/app.tsx @@ -0,0 +1,35 @@ +import Router from "preact-router"; +import { Shell } from "./components/layout/shell"; +import { ToastContainer } from "./components/ui/toast"; +import { CircuitBreakers } from "./pages/circuit-breakers"; +import { DeadLetters } from "./pages/dead-letters"; +import { JobDetail } from "./pages/job-detail"; +import { Jobs } from "./pages/jobs"; +import { Logs } from "./pages/logs"; +import { Metrics } from "./pages/metrics"; +import { Overview } from "./pages/overview"; +import { Queues } from "./pages/queues"; +import { Resources } from "./pages/resources"; +import { System } from "./pages/system"; +import { Workers } from "./pages/workers"; + +export function App() { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/dashboard/src/charts/dag-viewer.tsx b/dashboard/src/charts/dag-viewer.tsx new file mode 100644 index 0000000..7e2c95f --- /dev/null +++ b/dashboard/src/charts/dag-viewer.tsx @@ -0,0 +1,143 @@ +import { route } from "preact-router"; +import type { DagData, DagNode, JobStatus } from "../api/types"; + +interface DagViewerProps { + dag: DagData; +} + +const STATUS_COLORS: Record = { + pending: "#ffa726", + running: "#42a5f5", + complete: "#66bb6a", + failed: "#ef5350", + dead: "#ef5350", + cancelled: "#a0a0b0", +}; + +export function DagViewer({ dag }: DagViewerProps) { + if (!dag.nodes || dag.nodes.length <= 1) return null; + + const nodeW = 160; + const nodeH = 36; + const gapX = 40; + const gapY = 20; + + // Build adjacency and in-degree + const adj: Record = {}; + const inDeg: Record = {}; + dag.nodes.forEach((n) => { + adj[n.id] = []; + inDeg[n.id] = 0; + }); + dag.edges.forEach((e) => { + if (!adj[e.from]) adj[e.from] = []; + adj[e.from].push(e.to); + inDeg[e.to] = (inDeg[e.to] || 0) + 1; + }); + + // BFS layer assignment + const layers: string[][] = []; + const placed = new Set(); + let queue = dag.nodes.filter((n) => (inDeg[n.id] || 0) === 0).map((n) => n.id); + while (queue.length) { + layers.push([...queue]); + for (const id of queue) placed.add(id); + const next: string[] = []; + queue.forEach((id) => { + (adj[id] || []).forEach((to) => { + if (!placed.has(to) && !next.includes(to)) next.push(to); + }); + }); + queue = next; + } + dag.nodes.forEach((n) => { + if (!placed.has(n.id)) { + layers.push([n.id]); + placed.add(n.id); + } + }); + + const nodeMap: Record = {}; + for (const n of dag.nodes) nodeMap[n.id] = n; + + const positions: Record = {}; + let svgW = 0; + let svgH = 0; + layers.forEach((layer, li) => { + layer.forEach((id, ni) => { + const x = 20 + li * (nodeW + gapX); + const y = 20 + ni * (nodeH + gapY); + positions[id] = { x, y }; + svgW = Math.max(svgW, x + nodeW + 20); + svgH = Math.max(svgH, y + nodeH + 20); + }); + }); + + return ( +
+

Dependency Graph

+
+ + Job dependency graph + + + + + + {dag.edges.map((e, i) => { + const from = positions[e.from]; + const to = positions[e.to]; + if (!from || !to) return null; + return ( + + ); + })} + {dag.nodes.map((n) => { + const p = positions[n.id]; + if (!p) return null; + const color = STATUS_COLORS[n.status] || "#a0a0b0"; + return ( + route(`/jobs/${n.id}`)}> + + + {n.status.toUpperCase()} + + + {n.task_name.length > 18 ? n.task_name.slice(-18) : n.task_name} + + + ); + })} + +
+
+ ); +} diff --git a/dashboard/src/charts/throughput-chart.tsx b/dashboard/src/charts/throughput-chart.tsx new file mode 100644 index 0000000..ea9ec3f --- /dev/null +++ b/dashboard/src/charts/throughput-chart.tsx @@ -0,0 +1,117 @@ +import { TrendingUp } from "lucide-preact"; +import { useEffect, useRef } from "preact/hooks"; + +interface ThroughputChartProps { + data: number[]; +} + +export function ThroughputChart({ data }: ThroughputChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + const w = rect.width; + const h = rect.height; + + ctx.clearRect(0, 0, w, h); + + if (data.length < 2) { + ctx.fillStyle = "rgba(139,149,165,0.4)"; + ctx.font = "12px -apple-system, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Collecting data\u2026", w / 2, h / 2); + return; + } + + const max = Math.max(...data, 1); + const pad = { top: 12, right: 12, bottom: 24, left: 44 }; + const cw = w - pad.left - pad.right; + const ch = h - pad.top - pad.bottom; + + // Grid lines + for (let i = 0; i <= 4; i++) { + const y = pad.top + ch * (1 - i / 4); + ctx.strokeStyle = "rgba(255,255,255,0.04)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + ctx.fillStyle = "rgba(139,149,165,0.5)"; + ctx.font = "10px -apple-system, sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(((max * i) / 4).toFixed(1), pad.left - 6, y + 3); + } + + // Gradient fill + const gradient = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch); + gradient.addColorStop(0, "rgba(34,197,94,0.2)"); + gradient.addColorStop(1, "rgba(34,197,94,0.01)"); + + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top + ch); + data.forEach((v, i) => { + const x = pad.left + (i / (data.length - 1)) * cw; + const y = pad.top + ch * (1 - v / max); + ctx.lineTo(x, y); + }); + ctx.lineTo(pad.left + cw, pad.top + ch); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // Line + ctx.beginPath(); + data.forEach((v, i) => { + const x = pad.left + (i / (data.length - 1)) * cw; + const y = pad.top + ch * (1 - v / max); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.strokeStyle = "#22c55e"; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.stroke(); + + // Current value dot + if (data.length > 0) { + const lastX = pad.left + cw; + const lastY = pad.top + ch * (1 - data[data.length - 1] / max); + ctx.beginPath(); + ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); + ctx.fillStyle = "#22c55e"; + ctx.fill(); + ctx.beginPath(); + ctx.arc(lastX, lastY, 5, 0, Math.PI * 2); + ctx.strokeStyle = "rgba(34,197,94,0.3)"; + ctx.lineWidth = 2; + ctx.stroke(); + } + }, [data]); + + const current = data.length > 0 ? data[data.length - 1] : 0; + + return ( +
+
+
+ +

Throughput

+
+ + {current.toFixed(1)} jobs/s + +
+ +
+ ); +} diff --git a/dashboard/src/charts/timeseries-chart.tsx b/dashboard/src/charts/timeseries-chart.tsx new file mode 100644 index 0000000..f1a8ef6 --- /dev/null +++ b/dashboard/src/charts/timeseries-chart.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from "preact/hooks"; +import type { TimeseriesBucket } from "../api/types"; + +interface TimeseriesChartProps { + data: TimeseriesBucket[]; +} + +export function TimeseriesChart({ data }: TimeseriesChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + const w = rect.width; + const h = rect.height; + + ctx.clearRect(0, 0, w, h); + + if (!data.length) { + ctx.fillStyle = "rgba(139,149,165,0.4)"; + ctx.font = "12px -apple-system, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("No timeseries data", w / 2, h / 2); + return; + } + + const pad = { top: 12, right: 12, bottom: 32, left: 48 }; + const cw = w - pad.left - pad.right; + const ch = h - pad.top - pad.bottom; + + const maxCount = Math.max(...data.map((d) => d.success + d.failure), 1); + const barW = Math.max(3, cw / data.length - 2); + const gap = Math.max(1, (cw - barW * data.length) / data.length); + + // Y-axis grid + for (let i = 0; i <= 4; i++) { + const y = pad.top + ch * (1 - i / 4); + ctx.strokeStyle = "rgba(255,255,255,0.04)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + ctx.fillStyle = "rgba(139,149,165,0.5)"; + ctx.font = "10px -apple-system, sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(Math.round((maxCount * i) / 4).toString(), pad.left - 6, y + 3); + } + + // Bars + data.forEach((d, i) => { + const x = pad.left + i * (barW + gap); + const successH = (d.success / maxCount) * ch; + const failureH = (d.failure / maxCount) * ch; + + // Success bar with rounded top + ctx.fillStyle = "rgba(34,197,94,0.65)"; + ctx.beginPath(); + const successY = pad.top + ch - successH - failureH; + ctx.roundRect(x, successY, barW, successH, [2, 2, 0, 0]); + ctx.fill(); + + // Failure bar stacked + if (failureH > 0) { + ctx.fillStyle = "rgba(239,68,68,0.65)"; + ctx.beginPath(); + ctx.roundRect(x, pad.top + ch - failureH, barW, failureH, [0, 0, 2, 2]); + ctx.fill(); + } + }); + + // X-axis timestamps + ctx.fillStyle = "rgba(139,149,165,0.5)"; + ctx.font = "10px -apple-system, sans-serif"; + ctx.textAlign = "center"; + const labelCount = Math.min(6, data.length); + for (let i = 0; i < labelCount; i++) { + const idx = Math.floor((i / (labelCount - 1 || 1)) * (data.length - 1)); + const d = new Date(data[idx].timestamp * 1000); + const label = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const x = pad.left + idx * (barW + gap) + barW / 2; + ctx.fillText(label, x, h - 10); + } + }, [data]); + + return ( +
+
+

Throughput Over Time

+
+ + + Success + + + + Failure + +
+
+ +
+ ); +} diff --git a/dashboard/src/components/layout/header.tsx b/dashboard/src/components/layout/header.tsx new file mode 100644 index 0000000..62b5d8c --- /dev/null +++ b/dashboard/src/components/layout/header.tsx @@ -0,0 +1,96 @@ +import { Moon, RefreshCw, Search, Sun, Zap } from "lucide-preact"; +import { useEffect, useState } from "preact/hooks"; +import { route } from "preact-router"; +import { lastRefreshAt, refreshInterval, setRefreshInterval } from "../../hooks/use-auto-refresh"; +import { theme, toggleTheme } from "../../hooks/use-theme"; + +function RelativeTime() { + const [ago, setAgo] = useState(""); + + useEffect(() => { + const update = () => { + const seconds = Math.round((Date.now() - lastRefreshAt.value) / 1000); + setAgo(seconds < 2 ? "just now" : `${seconds}s ago`); + }; + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [lastRefreshAt.value]); + + return {ago}; +} + +export function Header() { + const [searchValue, setSearchValue] = useState(""); + + const handleSearch = (e: Event) => { + e.preventDefault(); + const id = searchValue.trim(); + if (id) { + route(`/jobs/${id}`); + setSearchValue(""); + } + }; + + return ( +
+ +
+ +
+ + taskito + + +
+ + {/* Job ID search */} +
+ + setSearchValue((e.target as HTMLInputElement).value)} + class="bg-transparent border-none outline-none text-[13px] dark:text-gray-200 text-slate-700 placeholder:text-muted/50 w-full" + /> + + +
+ {/* Refresh interval + indicator */} +
+ 0 ? "text-accent" : "text-muted"}`} + strokeWidth={refreshInterval.value > 0 ? 2 : 1.5} + /> + + +
+ +
+ + {/* Theme toggle */} + +
+
+ ); +} diff --git a/dashboard/src/components/layout/shell.tsx b/dashboard/src/components/layout/shell.tsx new file mode 100644 index 0000000..0d65daf --- /dev/null +++ b/dashboard/src/components/layout/shell.tsx @@ -0,0 +1,21 @@ +import type { ComponentChildren } from "preact"; +import { Header } from "./header"; +import { Sidebar } from "./sidebar"; + +interface ShellProps { + children: ComponentChildren; +} + +export function Shell({ children }: ShellProps) { + return ( +
+
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..0064754 --- /dev/null +++ b/dashboard/src/components/layout/sidebar.tsx @@ -0,0 +1,113 @@ +import type { LucideIcon } from "lucide-preact"; +import { + BarChart3, + Box, + Cog, + Layers, + LayoutDashboard, + ListTodo, + ScrollText, + Server, + ShieldAlert, + Skull, +} from "lucide-preact"; +import { useEffect, useState } from "preact/hooks"; +import { getCurrentUrl } from "preact-router"; + +interface NavItem { + path: string; + label: string; + icon: LucideIcon; +} + +interface NavGroup { + title: string; + items: NavItem[]; +} + +const NAV_GROUPS: NavGroup[] = [ + { + title: "Monitoring", + items: [ + { path: "/", label: "Overview", icon: LayoutDashboard }, + { path: "/jobs", label: "Jobs", icon: ListTodo }, + { path: "/metrics", label: "Metrics", icon: BarChart3 }, + { path: "/logs", label: "Logs", icon: ScrollText }, + ], + }, + { + title: "Infrastructure", + items: [ + { path: "/workers", label: "Workers", icon: Server }, + { path: "/queues", label: "Queues", icon: Layers }, + { path: "/resources", label: "Resources", icon: Box }, + { path: "/circuit-breakers", label: "Circuit Breakers", icon: ShieldAlert }, + ], + }, + { + title: "Advanced", + items: [ + { path: "/dead-letters", label: "Dead Letters", icon: Skull }, + { path: "/system", label: "System", icon: Cog }, + ], + }, +]; + +function isActive(current: string, path: string): boolean { + if (path === "/") return current === "/"; + return current === path || current.startsWith(`${path}/`); +} + +export function Sidebar() { + const [currentPath, setCurrentPath] = useState(getCurrentUrl()); + + useEffect(() => { + const handler = () => setCurrentPath(getCurrentUrl()); + addEventListener("popstate", handler); + addEventListener("pushstate", handler); + return () => { + removeEventListener("popstate", handler); + removeEventListener("pushstate", handler); + }; + }, []); + + return ( + + ); +} diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx new file mode 100644 index 0000000..41f6361 --- /dev/null +++ b/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import type { JobStatus } from "../../api/types"; + +const STATUS_STYLES: Record = { + pending: "bg-warning/10 text-warning border-warning/20", + running: "bg-info/10 text-info border-info/20", + complete: "bg-success/10 text-success border-success/20", + failed: "bg-danger/10 text-danger border-danger/20", + dead: "bg-danger/15 text-danger border-danger/25", + cancelled: "bg-muted/10 text-muted border-muted/20", + closed: "bg-success/10 text-success border-success/20", + open: "bg-danger/10 text-danger border-danger/20", + half_open: "bg-warning/10 text-warning border-warning/20", + healthy: "bg-success/10 text-success border-success/20", + unhealthy: "bg-danger/10 text-danger border-danger/20", + degraded: "bg-warning/10 text-warning border-warning/20", + active: "bg-success/10 text-success border-success/20", + paused: "bg-warning/10 text-warning border-warning/20", +}; + +const DOT_COLORS: Record = { + pending: "bg-warning", + running: "bg-info", + complete: "bg-success", + failed: "bg-danger", + dead: "bg-danger", + cancelled: "bg-muted", + closed: "bg-success", + open: "bg-danger", + half_open: "bg-warning", + healthy: "bg-success", + unhealthy: "bg-danger", + degraded: "bg-warning", + active: "bg-success", + paused: "bg-warning", +}; + +interface BadgeProps { + status: JobStatus | string; +} + +export function Badge({ status }: BadgeProps) { + const style = STATUS_STYLES[status] ?? "bg-muted/10 text-muted border-muted/20"; + const dot = DOT_COLORS[status] ?? "bg-muted"; + return ( + + + {status} + + ); +} diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx new file mode 100644 index 0000000..91a4cda --- /dev/null +++ b/dashboard/src/components/ui/button.tsx @@ -0,0 +1,37 @@ +import type { ComponentChildren } from "preact"; + +interface ButtonProps { + onClick?: () => void; + variant?: "primary" | "danger" | "ghost"; + disabled?: boolean; + children: ComponentChildren; + class?: string; +} + +const VARIANTS: Record = { + primary: + "bg-accent text-white shadow-sm shadow-accent/20 hover:bg-accent/90 hover:shadow-md hover:shadow-accent/25 active:scale-[0.98]", + danger: + "bg-danger text-white shadow-sm shadow-danger/20 hover:bg-danger/90 hover:shadow-md hover:shadow-danger/25 active:scale-[0.98]", + ghost: + "dark:text-gray-400 text-slate-500 hover:dark:bg-surface-3 hover:bg-slate-100 hover:dark:text-gray-200 hover:text-slate-700 active:scale-[0.98]", +}; + +export function Button({ + onClick, + variant = "primary", + disabled, + children, + class: className = "", +}: ButtonProps) { + return ( + + ); +} diff --git a/dashboard/src/components/ui/confirm-dialog.tsx b/dashboard/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..296c924 --- /dev/null +++ b/dashboard/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,37 @@ +import { AlertTriangle } from "lucide-preact"; +import { Button } from "./button"; + +interface ConfirmDialogProps { + message: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) { + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

{message}

+
+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/components/ui/data-table.tsx b/dashboard/src/components/ui/data-table.tsx new file mode 100644 index 0000000..fba5878 --- /dev/null +++ b/dashboard/src/components/ui/data-table.tsx @@ -0,0 +1,116 @@ +import type { ComponentChildren } from "preact"; + +export interface Column { + header: string; + accessor: keyof T | ((row: T) => ComponentChildren); + className?: string; +} + +interface DataTableProps { + columns: Column[]; + data: T[]; + onRowClick?: (row: T) => void; + children?: ComponentChildren; + selectable?: boolean; + selectedKeys?: Set; + rowKey?: (row: T) => string; + onSelectionChange?: (keys: Set) => void; +} + +export function DataTable({ + columns, + data, + onRowClick, + children, + selectable, + selectedKeys, + rowKey, + onSelectionChange, +}: DataTableProps) { + const allKeys = selectable && rowKey ? data.map(rowKey) : []; + const allSelected = + selectable && allKeys.length > 0 && allKeys.every((k) => selectedKeys?.has(k)); + + const toggleAll = () => { + if (!onSelectionChange) return; + onSelectionChange(allSelected ? new Set() : new Set(allKeys)); + }; + + const toggleRow = (key: string) => { + if (!onSelectionChange || !selectedKeys) return; + const next = new Set(selectedKeys); + if (next.has(key)) next.delete(key); + else next.add(key); + onSelectionChange(next); + }; + + return ( +
+
+ + + + {selectable && ( + + )} + {columns.map((col, i) => ( + + ))} + + + + {data.map((row, ri) => { + const key = selectable && rowKey ? rowKey(row) : String(ri); + const isSelected = selectedKeys?.has(key); + return ( + onRowClick(row) : undefined} + class={`border-b dark:border-white/[0.03] border-slate-50 last:border-0 transition-colors duration-100 ${ + ri % 2 === 1 ? "dark:bg-white/[0.01] bg-slate-50/30" : "" + } ${isSelected ? "dark:bg-accent/[0.08] bg-accent/[0.04]" : ""} ${ + onRowClick + ? "cursor-pointer hover:dark:bg-accent/[0.04] hover:bg-accent/[0.02]" + : "" + }`} + > + {selectable && ( + + )} + {columns.map((col, ci) => ( + + ))} + + ); + })} + +
+ + + {col.header} +
+ toggleRow(key)} + onClick={(e) => e.stopPropagation()} + class="accent-accent cursor-pointer" + /> + + {typeof col.accessor === "function" + ? col.accessor(row) + : (row[col.accessor] as ComponentChildren)} +
+
+ {children} +
+ ); +} diff --git a/dashboard/src/components/ui/empty-state.tsx b/dashboard/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..52f5f47 --- /dev/null +++ b/dashboard/src/components/ui/empty-state.tsx @@ -0,0 +1,18 @@ +import { Inbox } from "lucide-preact"; + +interface EmptyStateProps { + message: string; + subtitle?: string; +} + +export function EmptyState({ message, subtitle }: EmptyStateProps) { + return ( +
+
+ +
+

{message}

+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/dashboard/src/components/ui/loading.tsx b/dashboard/src/components/ui/loading.tsx new file mode 100644 index 0000000..a30a5c9 --- /dev/null +++ b/dashboard/src/components/ui/loading.tsx @@ -0,0 +1,39 @@ +export function Loading() { + return ( +
+
+
+ Loading\u2026 +
+
+ ); +} + +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ); +} + +export function CardSkeleton() { + return ( +
+
+
+
+
+ ); +} diff --git a/dashboard/src/components/ui/pagination.tsx b/dashboard/src/components/ui/pagination.tsx new file mode 100644 index 0000000..0d4c29f --- /dev/null +++ b/dashboard/src/components/ui/pagination.tsx @@ -0,0 +1,38 @@ +import { ChevronLeft, ChevronRight } from "lucide-preact"; + +interface PaginationProps { + page: number; + pageSize: number; + itemCount: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ page, pageSize, itemCount, onPageChange }: PaginationProps) { + return ( +
+ + Showing {page * pageSize + 1}\u2013{page * pageSize + itemCount} items + +
+ + +
+
+ ); +} diff --git a/dashboard/src/components/ui/progress-bar.tsx b/dashboard/src/components/ui/progress-bar.tsx new file mode 100644 index 0000000..943823b --- /dev/null +++ b/dashboard/src/components/ui/progress-bar.tsx @@ -0,0 +1,20 @@ +interface ProgressBarProps { + progress: number | null; +} + +export function ProgressBar({ progress }: ProgressBarProps) { + if (progress == null) { + return {"\u2014"}; + } + return ( + + + + + {progress}% + + ); +} diff --git a/dashboard/src/components/ui/stat-card.tsx b/dashboard/src/components/ui/stat-card.tsx new file mode 100644 index 0000000..b19241b --- /dev/null +++ b/dashboard/src/components/ui/stat-card.tsx @@ -0,0 +1,55 @@ +import type { LucideIcon } from "lucide-preact"; +import { Ban, CheckCircle2, Clock, Play, Skull, XCircle } from "lucide-preact"; +import { fmtNumber } from "../../lib/format"; + +interface StatCardProps { + label: string; + value: number; + color?: string; +} + +const STAT_CONFIG: Record = + { + pending: { + color: "text-warning", + bg: "bg-warning-dim", + border: "border-l-warning", + icon: Clock, + }, + running: { color: "text-info", bg: "bg-info-dim", border: "border-l-info", icon: Play }, + completed: { + color: "text-success", + bg: "bg-success-dim", + border: "border-l-success", + icon: CheckCircle2, + }, + failed: { color: "text-danger", bg: "bg-danger-dim", border: "border-l-danger", icon: XCircle }, + dead: { color: "text-danger", bg: "bg-danger-dim", border: "border-l-danger", icon: Skull }, + cancelled: { color: "text-muted", bg: "bg-muted/10", border: "border-l-muted/40", icon: Ban }, + }; + +export function StatCard({ label, value, color }: StatCardProps) { + const config = STAT_CONFIG[label]; + const textColor = color ?? config?.color ?? "text-accent-light"; + const bg = config?.bg ?? "bg-accent-dim"; + const border = config?.border ?? "border-l-accent"; + const Icon = config?.icon ?? Clock; + + return ( +
+
+
+
+ {fmtNumber(value)} +
+
{label}
+
+
+ +
+
+
+ ); +} diff --git a/dashboard/src/components/ui/stats-grid.tsx b/dashboard/src/components/ui/stats-grid.tsx new file mode 100644 index 0000000..6f1e3c0 --- /dev/null +++ b/dashboard/src/components/ui/stats-grid.tsx @@ -0,0 +1,25 @@ +import type { QueueStats } from "../../api/types"; +import { StatCard } from "./stat-card"; + +interface StatsGridProps { + stats: QueueStats; +} + +const STAT_KEYS: (keyof QueueStats)[] = [ + "pending", + "running", + "completed", + "failed", + "dead", + "cancelled", +]; + +export function StatsGrid({ stats }: StatsGridProps) { + return ( +
+ {STAT_KEYS.map((key) => ( + + ))} +
+ ); +} diff --git a/dashboard/src/components/ui/toast.tsx b/dashboard/src/components/ui/toast.tsx new file mode 100644 index 0000000..35f65cd --- /dev/null +++ b/dashboard/src/components/ui/toast.tsx @@ -0,0 +1,42 @@ +import { CheckCircle2, Info, X, XCircle } from "lucide-preact"; +import { dismissToast, type Toast, toasts } from "../../hooks/use-toast"; + +const TYPE_CONFIG: Record< + Toast["type"], + { border: string; icon: typeof CheckCircle2; iconColor: string } +> = { + success: { border: "border-l-success", icon: CheckCircle2, iconColor: "text-success" }, + error: { border: "border-l-danger", icon: XCircle, iconColor: "text-danger" }, + info: { border: "border-l-info", icon: Info, iconColor: "text-info" }, +}; + +export function ToastContainer() { + const items = toasts.value; + if (!items.length) return null; + + return ( +
+ {items.map((t) => { + const config = TYPE_CONFIG[t.type]; + const Icon = config.icon; + return ( + + ); + })} +
+ ); +} diff --git a/dashboard/src/hooks/use-api.ts b/dashboard/src/hooks/use-api.ts new file mode 100644 index 0000000..bae158e --- /dev/null +++ b/dashboard/src/hooks/use-api.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { api } from "../api/client"; +import { markRefreshed, refreshInterval } from "./use-auto-refresh"; + +interface UseApiResult { + data: T | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useApi(url: string | null, deps: unknown[] = []): UseApiResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const mountedRef = useRef(true); + + const fetchData = useCallback(() => { + if (!url) { + setData(null); + setLoading(false); + return; + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading((prev) => (data === null ? true : prev)); + + api(url, controller.signal) + .then((result) => { + if (mountedRef.current && !controller.signal.aborted) { + setData(result); + setError(null); + setLoading(false); + markRefreshed(); + } + }) + .catch((err) => { + if (mountedRef.current && !controller.signal.aborted) { + setError(err.message ?? "Failed to fetch"); + setLoading(false); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url, ...deps]); + + useEffect(() => { + mountedRef.current = true; + fetchData(); + return () => { + mountedRef.current = false; + abortRef.current?.abort(); + }; + }, [fetchData]); + + // Auto-refresh + useEffect(() => { + const ms = refreshInterval.value; + if (ms <= 0 || !url) return; + const timer = setInterval(fetchData, ms); + return () => clearInterval(timer); + }, [fetchData, url, refreshInterval.value]); + + return { data, loading, error, refetch: fetchData }; +} diff --git a/dashboard/src/hooks/use-auto-refresh.ts b/dashboard/src/hooks/use-auto-refresh.ts new file mode 100644 index 0000000..96739f2 --- /dev/null +++ b/dashboard/src/hooks/use-auto-refresh.ts @@ -0,0 +1,12 @@ +import { signal } from "@preact/signals"; + +export const refreshInterval = signal(5000); +export const lastRefreshAt = signal(Date.now()); + +export function setRefreshInterval(ms: number): void { + refreshInterval.value = ms; +} + +export function markRefreshed(): void { + lastRefreshAt.value = Date.now(); +} diff --git a/dashboard/src/hooks/use-theme.ts b/dashboard/src/hooks/use-theme.ts new file mode 100644 index 0000000..9199ad1 --- /dev/null +++ b/dashboard/src/hooks/use-theme.ts @@ -0,0 +1,23 @@ +import { effect, signal } from "@preact/signals"; + +type Theme = "dark" | "light"; + +const stored = localStorage.getItem("taskito-theme") as Theme | null; + +export const theme = signal(stored ?? "dark"); + +effect(() => { + const root = document.documentElement; + if (theme.value === "dark") { + root.classList.add("dark"); + root.classList.remove("light"); + } else { + root.classList.remove("dark"); + root.classList.add("light"); + } + localStorage.setItem("taskito-theme", theme.value); +}); + +export function toggleTheme(): void { + theme.value = theme.value === "dark" ? "light" : "dark"; +} diff --git a/dashboard/src/hooks/use-toast.ts b/dashboard/src/hooks/use-toast.ts new file mode 100644 index 0000000..aa66601 --- /dev/null +++ b/dashboard/src/hooks/use-toast.ts @@ -0,0 +1,23 @@ +import { signal } from "@preact/signals"; + +export interface Toast { + id: string; + message: string; + type: "success" | "error" | "info"; +} + +export const toasts = signal([]); + +let counter = 0; + +export function addToast(message: string, type: Toast["type"] = "info", duration = 3000): void { + const id = String(++counter); + toasts.value = [...toasts.value, { id, message, type }]; + setTimeout(() => { + toasts.value = toasts.value.filter((t) => t.id !== id); + }, duration); +} + +export function dismissToast(id: string): void { + toasts.value = toasts.value.filter((t) => t.id !== id); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..7469947 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,133 @@ +@import "tailwindcss"; + +@theme { + --color-surface: #0f1117; + --color-surface-2: #161b22; + --color-surface-3: #1c2333; + --color-surface-4: #252d3a; + --color-surface-5: #2d3748; + --color-accent: #7c4dff; + --color-accent-light: #b388ff; + --color-accent-dim: #7c4dff33; + --color-success: #22c55e; + --color-success-dim: #22c55e22; + --color-warning: #f59e0b; + --color-warning-dim: #f59e0b22; + --color-danger: #ef4444; + --color-danger-dim: #ef444422; + --color-info: #3b82f6; + --color-info-dim: #3b82f622; + --color-cyan: #06b6d4; + --color-muted: #8b95a5; + --color-border: #ffffff0d; +} + +* { + box-sizing: border-box; +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + min-height: 100vh; +} + +.dark body { + background: var(--color-surface); + color: #e2e8f0; +} + +html:not(.dark) body { + background: #f8fafc; + color: #1e293b; +} + +/* Scrollbar */ +.dark ::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.dark ::-webkit-scrollbar-track { + background: transparent; +} + +.dark ::-webkit-scrollbar-thumb { + background: var(--color-surface-4); + border-radius: 3px; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: var(--color-surface-5); +} + +/* Animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.7; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.animate-slide-in { + animation: slideIn 0.25s ease-out; +} + +.animate-fade-in { + animation: fadeIn 0.2s ease-out; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + var(--color-surface-3) 25%, + var(--color-surface-4) 50%, + var(--color-surface-3) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +/* Monospace */ +.font-mono { + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "SF Mono", monospace; +} + +/* Focus */ +*:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: 4px; +} diff --git a/dashboard/src/lib/format.ts b/dashboard/src/lib/format.ts new file mode 100644 index 0000000..30f7e7c --- /dev/null +++ b/dashboard/src/lib/format.ts @@ -0,0 +1,27 @@ +export function fmtTime(ms: number | null | undefined): string { + if (!ms) return "\u2014"; + const d = new Date(ms); + return ( + d.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + " " + + d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + ); +} + +export function fmtDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60_000).toFixed(1)}m`; +} + +export function fmtNumber(n: number): string { + return n.toLocaleString(); +} + +export function truncateId(id: string, len = 8): string { + return id.length > len ? id.slice(0, len) : id; +} diff --git a/dashboard/src/lib/routes.ts b/dashboard/src/lib/routes.ts new file mode 100644 index 0000000..edb5b5b --- /dev/null +++ b/dashboard/src/lib/routes.ts @@ -0,0 +1,18 @@ +export const ROUTES = { + OVERVIEW: "/", + JOBS: "/jobs", + JOB_DETAIL: "/jobs/:id", + METRICS: "/metrics", + LOGS: "/logs", + WORKERS: "/workers", + CIRCUIT_BREAKERS: "/circuit-breakers", + DEAD_LETTERS: "/dead-letters", + RESOURCES: "/resources", + QUEUES: "/queues", + SYSTEM: "/system", +} as const; + +/** Props injected by preact-router on routed components. */ +export interface RoutableProps { + path?: string; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..b2c3c36 --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,5 @@ +import { render } from "preact"; +import { App } from "./app"; +import "./index.css"; + +render(, document.getElementById("app") as HTMLElement); diff --git a/dashboard/src/pages/circuit-breakers.tsx b/dashboard/src/pages/circuit-breakers.tsx new file mode 100644 index 0000000..a02b20f --- /dev/null +++ b/dashboard/src/pages/circuit-breakers.tsx @@ -0,0 +1,58 @@ +import { ShieldAlert } from "lucide-preact"; +import type { CircuitBreaker as CBType } from "../api/types"; +import { Badge } from "../components/ui/badge"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import { fmtTime } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +const CB_COLUMNS: Column[] = [ + { header: "Task", accessor: (b) => {b.task_name} }, + { header: "State", accessor: (b) => }, + { + header: "Failures", + accessor: (b) => ( + 0 ? "text-danger tabular-nums" : "tabular-nums"}> + {b.failure_count} + + ), + }, + { header: "Threshold", accessor: (b) => {b.threshold} }, + { header: "Window", accessor: (b) => `${(b.window_ms / 1000).toFixed(0)}s` }, + { header: "Cooldown", accessor: (b) => `${(b.cooldown_ms / 1000).toFixed(0)}s` }, + { + header: "Last Failure", + accessor: (b) => {fmtTime(b.last_failure_at)}, + }, +]; + +export function CircuitBreakers(_props: RoutableProps) { + const { data: breakers, loading } = useApi("/api/circuit-breakers"); + + if (loading && !breakers) return ; + + return ( +
+
+
+ +
+
+

Circuit Breakers

+

Automatic failure protection status

+
+
+ + {!breakers?.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/pages/dead-letters.tsx b/dashboard/src/pages/dead-letters.tsx new file mode 100644 index 0000000..1da06ea --- /dev/null +++ b/dashboard/src/pages/dead-letters.tsx @@ -0,0 +1,265 @@ +import { ChevronDown, ChevronRight, Group, List, RotateCcw, Skull, Trash2 } from "lucide-preact"; +import { useState } from "preact/hooks"; +import { apiPost } from "../api/client"; +import type { DeadLetter } from "../api/types"; +import { Button } from "../components/ui/button"; +import { ConfirmDialog } from "../components/ui/confirm-dialog"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { Pagination } from "../components/ui/pagination"; +import { useApi } from "../hooks/use-api"; +import { addToast } from "../hooks/use-toast"; +import { fmtTime, truncateId } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +const PAGE_SIZE = 20; + +interface ErrorGroup { + error: string; + items: DeadLetter[]; +} + +function groupByError(items: DeadLetter[]): ErrorGroup[] { + const map = new Map(); + for (const item of items) { + const key = item.error ?? "(no error message)"; + const list = map.get(key); + if (list) list.push(item); + else map.set(key, [item]); + } + return Array.from(map.entries()) + .map(([error, items]) => ({ error, items })) + .sort((a, b) => b.items.length - a.items.length); +} + +export function DeadLetters(_props: RoutableProps) { + const [page, setPage] = useState(0); + const [showPurge, setShowPurge] = useState(false); + const [grouped, setGrouped] = useState(false); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const { + data: items, + loading, + refetch, + } = useApi( + `/api/dead-letters?limit=${grouped ? 200 : PAGE_SIZE}&offset=${grouped ? 0 : page * PAGE_SIZE}`, + [page, grouped], + ); + + const handleRetry = async (id: string) => { + try { + await apiPost<{ new_job_id: string }>(`/api/dead-letters/${id}/retry`); + addToast("Dead letter retried", "success"); + refetch(); + } catch { + addToast("Failed to retry dead letter", "error"); + } + }; + + const handleRetryGroup = async (group: ErrorGroup) => { + let retried = 0; + for (const item of group.items) { + try { + await apiPost<{ new_job_id: string }>(`/api/dead-letters/${item.id}/retry`); + retried++; + } catch { + /* skip failed */ + } + } + addToast( + `Retried ${retried} of ${group.items.length} dead letters`, + retried > 0 ? "success" : "error", + ); + refetch(); + }; + + const handlePurge = async () => { + setShowPurge(false); + try { + const res = await apiPost<{ purged: number }>("/api/dead-letters/purge"); + addToast(`Purged ${res.purged} dead letters`, "success"); + refetch(); + } catch { + addToast("Failed to purge dead letters", "error"); + } + }; + + const toggleGroup = (error: string) => { + const next = new Set(expandedGroups); + if (next.has(error)) next.delete(error); + else next.add(error); + setExpandedGroups(next); + }; + + const columns: Column[] = [ + { + header: "ID", + accessor: (d) => {truncateId(d.id)}, + }, + { + header: "Original Job", + accessor: (d) => ( + + {truncateId(d.original_job_id)} + + ), + }, + { header: "Task", accessor: (d) => {d.task_name} }, + { header: "Queue", accessor: "queue" }, + { + header: "Error", + accessor: (d) => ( + + {d.error ? (d.error.length > 50 ? `${d.error.slice(0, 50)}\u2026` : d.error) : "\u2014"} + + ), + className: "max-w-[250px]", + }, + { + header: "Retries", + accessor: (d) => {d.retry_count}, + }, + { + header: "Failed At", + accessor: (d) => {fmtTime(d.failed_at)}, + }, + { + header: "Actions", + accessor: (d) => ( + + ), + }, + ]; + + if (loading && !items) return ; + + const groups = grouped && items ? groupByError(items) : []; + + return ( +
+
+
+
+ +
+
+

Dead Letters

+

Failed jobs that exhausted all retries

+
+
+
+ {items && items.length > 0 && ( + <> +
+ + +
+ + + )} +
+
+ + {!items?.length ? ( + + ) : grouped ? ( + /* Grouped view */ +
+ {groups.map((group) => { + const isExpanded = expandedGroups.has(group.error); + return ( +
+
toggleGroup(group.error)} + > + {isExpanded ? ( + + ) : ( + + )} +
+ {group.error} +
+ + {group.items.length} + + +
+ {isExpanded && ( +
+ +
+ )} +
+ ); + })} +
+ ) : ( + /* List view */ + + + + )} + + {showPurge && ( + setShowPurge(false)} + /> + )} +
+ ); +} diff --git a/dashboard/src/pages/job-detail.tsx b/dashboard/src/pages/job-detail.tsx new file mode 100644 index 0000000..0651fb3 --- /dev/null +++ b/dashboard/src/pages/job-detail.tsx @@ -0,0 +1,232 @@ +import { FileText, RotateCcw } from "lucide-preact"; +import { route } from "preact-router"; +import { apiPost } from "../api/client"; +import type { DagData, Job, JobError, ReplayEntry, TaskLog } from "../api/types"; +import { DagViewer } from "../charts/dag-viewer"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { useApi } from "../hooks/use-api"; +import { addToast } from "../hooks/use-toast"; +import { fmtTime, truncateId } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +interface JobDetailProps extends RoutableProps { + id?: string; +} + +const ERROR_COLUMNS: Column[] = [ + { header: "Attempt", accessor: "attempt" }, + { header: "Error", accessor: "error", className: "max-w-xs truncate" }, + { header: "Failed At", accessor: (e) => {fmtTime(e.failed_at)} }, +]; + +const LOG_COLUMNS: Column[] = [ + { header: "Time", accessor: (l) => {fmtTime(l.logged_at)} }, + { + header: "Level", + accessor: (l) => ( + + ), + }, + { header: "Message", accessor: "message" }, + { header: "Extra", accessor: (l) => l.extra ?? "\u2014", className: "max-w-[200px] truncate" }, +]; + +const REPLAY_COLUMNS: Column[] = [ + { + header: "Replay Job", + accessor: (r) => ( + + {truncateId(r.replay_job_id)} + + ), + }, + { + header: "Replayed At", + accessor: (r) => {fmtTime(r.replayed_at)}, + }, + { + header: "Original Error", + accessor: (r) => r.original_error ?? "\u2014", + className: "max-w-[200px] truncate", + }, + { + header: "Replay Error", + accessor: (r) => r.replay_error ?? "\u2014", + className: "max-w-[200px] truncate", + }, +]; + +export function JobDetail({ id }: JobDetailProps) { + const { data: job, loading, refetch } = useApi(`/api/jobs/${id}`); + const { data: errors } = useApi(`/api/jobs/${id}/errors`); + const { data: logs } = useApi(`/api/jobs/${id}/logs`); + const { data: replayHistory } = useApi(`/api/jobs/${id}/replay-history`); + const { data: dag } = useApi(`/api/jobs/${id}/dag`); + + if (loading && !job) return ; + if (!job) return ; + + const handleCancel = async () => { + try { + const res = await apiPost<{ cancelled: boolean }>(`/api/jobs/${id}/cancel`); + addToast( + res.cancelled ? "Job cancelled" : "Failed to cancel job", + res.cancelled ? "success" : "error", + ); + refetch(); + } catch { + addToast("Failed to cancel job", "error"); + } + }; + + const handleReplay = async () => { + try { + const res = await apiPost<{ replay_job_id: string }>(`/api/jobs/${id}/replay`); + addToast("Job replayed", "success"); + route(`/jobs/${res.replay_job_id}`); + } catch { + addToast("Failed to replay job", "error"); + } + }; + + // Determine accent color for the detail card border + const borderColor: Record = { + pending: "border-t-warning", + running: "border-t-info", + complete: "border-t-success", + failed: "border-t-danger", + dead: "border-t-danger", + cancelled: "border-t-muted", + }; + + return ( +
+
+
+ +
+
+

+ Job {truncateId(job.id)} +

+

{job.task_name}

+
+
+ +
+
+ ID + + {job.id} + + Status + + + + Task + {job.task_name} + Queue + {job.queue} + Priority + {job.priority} + Progress + + + + Retries + 0 ? "text-warning" : ""}> + {job.retry_count} / {job.max_retries} + + Created + {fmtTime(job.created_at)} + Scheduled + {fmtTime(job.scheduled_at)} + Started + {job.started_at ? fmtTime(job.started_at) : "\u2014"} + Completed + {job.completed_at ? fmtTime(job.completed_at) : "\u2014"} + Timeout + {(job.timeout_ms / 1000).toFixed(0)}s + {job.error && ( + <> + Error + + {job.error} + + + )} + {job.unique_key && ( + <> + Unique Key + {job.unique_key} + + )} + {job.metadata && ( + <> + Metadata + {job.metadata} + + )} +
+
+ {job.status === "pending" && ( + + )} + +
+
+ + {errors && errors.length > 0 && ( +
+

+ Error History ({errors.length}) +

+ +
+ )} + + {logs && logs.length > 0 && ( +
+

+ Task Logs ({logs.length}) +

+ +
+ )} + + {replayHistory && replayHistory.length > 0 && ( +
+

+ Replay History ({replayHistory.length}) +

+ +
+ )} + + {dag && } + + +
+ ); +} diff --git a/dashboard/src/pages/jobs.tsx b/dashboard/src/pages/jobs.tsx new file mode 100644 index 0000000..e9c6f76 --- /dev/null +++ b/dashboard/src/pages/jobs.tsx @@ -0,0 +1,274 @@ +import { Ban, ListTodo, RotateCcw, Search, X } from "lucide-preact"; +import { useState } from "preact/hooks"; +import { route } from "preact-router"; +import { apiPost } from "../api/client"; +import type { Job, QueueStats } from "../api/types"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { ConfirmDialog } from "../components/ui/confirm-dialog"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { Pagination } from "../components/ui/pagination"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { StatsGrid } from "../components/ui/stats-grid"; +import { useApi } from "../hooks/use-api"; +import { addToast } from "../hooks/use-toast"; +import { fmtTime, truncateId } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +interface Filters { + status: string; + queue: string; + task: string; + metadata: string; + error: string; + created_after: string; + created_before: string; +} + +const PAGE_SIZE = 20; + +const JOB_COLUMNS: Column[] = [ + { + header: "ID", + accessor: (j) => {truncateId(j.id)}, + }, + { header: "Task", accessor: "task_name" }, + { header: "Queue", accessor: "queue" }, + { header: "Status", accessor: (j) => }, + { header: "Priority", accessor: "priority" }, + { header: "Progress", accessor: (j) => }, + { + header: "Retries", + accessor: (j) => ( + 0 ? "text-warning" : "text-muted"}> + {j.retry_count}/{j.max_retries} + + ), + }, + { + header: "Created", + accessor: (j) => {fmtTime(j.created_at)}, + }, +]; + +function buildUrl(filters: Filters, page: number): string { + const params = new URLSearchParams(); + params.set("limit", String(PAGE_SIZE)); + params.set("offset", String(page * PAGE_SIZE)); + if (filters.status) params.set("status", filters.status); + if (filters.queue) params.set("queue", filters.queue); + if (filters.task) params.set("task", filters.task); + if (filters.metadata) params.set("metadata", filters.metadata); + if (filters.error) params.set("error", filters.error); + if (filters.created_after) + params.set("created_after", String(new Date(filters.created_after).getTime())); + if (filters.created_before) + params.set("created_before", String(new Date(filters.created_before).getTime())); + return `/api/jobs?${params}`; +} + +export function Jobs(_props: RoutableProps) { + const [filters, setFilters] = useState({ + status: "", + queue: "", + task: "", + metadata: "", + error: "", + created_after: "", + created_before: "", + }); + const [page, setPage] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [showBulkCancel, setShowBulkCancel] = useState(false); + + const { data: stats } = useApi("/api/stats"); + const { + data: jobs, + loading, + refetch, + } = useApi(buildUrl(filters, page), [ + filters.status, + filters.queue, + filters.task, + filters.metadata, + filters.error, + filters.created_after, + filters.created_before, + page, + ]); + + const updateFilter = (key: keyof Filters, value: string) => { + setFilters((f) => ({ ...f, [key]: value })); + setPage(0); + setSelected(new Set()); + }; + + const handleBulkCancel = async () => { + setShowBulkCancel(false); + let cancelled = 0; + for (const id of selected) { + try { + const res = await apiPost<{ cancelled: boolean }>(`/api/jobs/${id}/cancel`); + if (res.cancelled) cancelled++; + } catch { + /* skip failed */ + } + } + addToast( + `Cancelled ${cancelled} of ${selected.size} jobs`, + cancelled > 0 ? "success" : "error", + ); + setSelected(new Set()); + refetch(); + }; + + const handleBulkReplay = async () => { + let replayed = 0; + for (const id of selected) { + try { + await apiPost<{ replay_job_id: string }>(`/api/jobs/${id}/replay`); + replayed++; + } catch { + /* skip failed */ + } + } + addToast(`Replayed ${replayed} of ${selected.size} jobs`, replayed > 0 ? "success" : "error"); + setSelected(new Set()); + refetch(); + }; + + const inputClass = + "dark:bg-surface-3 bg-white dark:text-gray-200 text-slate-700 border dark:border-white/[0.06] border-slate-200 rounded-lg px-3 py-2 text-[13px] placeholder:text-muted/50 focus:border-accent/50 transition-colors"; + + return ( +
+
+
+ +
+
+

Jobs

+

Browse and filter task queue jobs

+
+
+ + {stats && } + +
+
+ + Filters +
+
+ + updateFilter("queue", (e.target as HTMLInputElement).value)} + /> + updateFilter("task", (e.target as HTMLInputElement).value)} + /> + updateFilter("metadata", (e.target as HTMLInputElement).value)} + /> + updateFilter("error", (e.target as HTMLInputElement).value)} + /> + updateFilter("created_after", (e.target as HTMLInputElement).value)} + /> + updateFilter("created_before", (e.target as HTMLInputElement).value)} + /> +
+
+ + {/* Bulk action bar */} + {selected.size > 0 && ( +
+ + {selected.size} job{selected.size > 1 ? "s" : ""} selected + +
+ + + +
+
+ )} + + {loading && !jobs ? ( + + ) : !jobs?.length ? ( + + ) : ( + route(`/jobs/${j.id}`)} + selectable + selectedKeys={selected} + rowKey={(j) => j.id} + onSelectionChange={setSelected} + > + + + )} + + {showBulkCancel && ( + 1 ? "s" : ""}? Only pending jobs can be cancelled.`} + onConfirm={handleBulkCancel} + onCancel={() => setShowBulkCancel(false)} + /> + )} +
+ ); +} diff --git a/dashboard/src/pages/logs.tsx b/dashboard/src/pages/logs.tsx new file mode 100644 index 0000000..a93b1a7 --- /dev/null +++ b/dashboard/src/pages/logs.tsx @@ -0,0 +1,92 @@ +import { ScrollText } from "lucide-preact"; +import { useState } from "preact/hooks"; +import type { TaskLog } from "../api/types"; +import { Badge } from "../components/ui/badge"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import { fmtTime, truncateId } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +const LOG_COLUMNS: Column[] = [ + { header: "Time", accessor: (l) => {fmtTime(l.logged_at)} }, + { + header: "Level", + accessor: (l) => ( + + ), + }, + { header: "Task", accessor: (l) => {l.task_name} }, + { + header: "Job", + accessor: (l) => ( + + {truncateId(l.job_id)} + + ), + }, + { header: "Message", accessor: "message" }, + { header: "Extra", accessor: (l) => l.extra ?? "\u2014", className: "max-w-[200px] truncate" }, +]; + +export function Logs(_props: RoutableProps) { + const [taskFilter, setTaskFilter] = useState(""); + const [levelFilter, setLevelFilter] = useState(""); + + const params = new URLSearchParams({ limit: "100" }); + if (taskFilter) params.set("task", taskFilter); + if (levelFilter) params.set("level", levelFilter); + + const { data: logs, loading } = useApi(`/api/logs?${params}`, [ + taskFilter, + levelFilter, + ]); + + const inputClass = + "dark:bg-surface-3 bg-white dark:text-gray-200 text-slate-700 border dark:border-white/[0.06] border-slate-200 rounded-lg px-3 py-2 text-[13px] placeholder:text-muted/50 focus:border-accent/50 transition-colors"; + + return ( +
+
+
+ +
+
+

Logs

+

Structured task execution logs

+
+
+ +
+ setTaskFilter((e.target as HTMLInputElement).value)} + /> + +
+ + {loading && !logs ? ( + + ) : !logs?.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/pages/metrics.tsx b/dashboard/src/pages/metrics.tsx new file mode 100644 index 0000000..9b0b84a --- /dev/null +++ b/dashboard/src/pages/metrics.tsx @@ -0,0 +1,133 @@ +import { BarChart3 } from "lucide-preact"; +import { useState } from "preact/hooks"; +import type { MetricsResponse, TaskMetrics, TimeseriesBucket } from "../api/types"; +import { TimeseriesChart } from "../charts/timeseries-chart"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import type { RoutableProps } from "../lib/routes"; + +interface MetricsRow extends TaskMetrics { + task_name: string; +} + +function latencyColor(ms: number, threshold: { good: number; warn: number }): string { + if (ms <= threshold.good) return "text-success"; + if (ms <= threshold.warn) return "text-warning"; + return "text-danger"; +} + +const METRICS_COLUMNS: Column[] = [ + { header: "Task", accessor: (r) => {r.task_name} }, + { header: "Total", accessor: (r) => {r.count} }, + { + header: "Success", + accessor: (r) => {r.success_count}, + }, + { + header: "Failures", + accessor: (r) => ( + 0 ? "text-danger tabular-nums" : "text-muted tabular-nums"}> + {r.failure_count} + + ), + }, + { + header: "Avg", + accessor: (r) => ( + + {r.avg_ms}ms + + ), + }, + { header: "P50", accessor: (r) => {r.p50_ms}ms }, + { + header: "P95", + accessor: (r) => ( + + {r.p95_ms}ms + + ), + }, + { + header: "P99", + accessor: (r) => ( + + {r.p99_ms}ms + + ), + }, + { header: "Min", accessor: (r) => {r.min_ms}ms }, + { + header: "Max", + accessor: (r) => ( + + {r.max_ms}ms + + ), + }, +]; + +const TIME_RANGES = [ + { label: "1h", seconds: 3600 }, + { label: "6h", seconds: 21600 }, + { label: "24h", seconds: 86400 }, +]; + +export function Metrics(_props: RoutableProps) { + const [since, setSince] = useState(3600); + const { data: metrics, loading } = useApi(`/api/metrics?since=${since}`, [ + since, + ]); + const { data: timeseries } = useApi( + `/api/metrics/timeseries?since=${since}&bucket=${since <= 3600 ? 60 : since <= 21600 ? 300 : 900}`, + [since], + ); + + const rows: MetricsRow[] = metrics + ? Object.entries(metrics).map(([task_name, m]) => ({ task_name, ...m })) + : []; + + return ( +
+
+
+
+ +
+
+

Metrics

+

Task performance and throughput

+
+
+
+ {TIME_RANGES.map((r) => ( + + ))} +
+
+ + {timeseries && timeseries.length > 0 && } + + {loading && !metrics ? ( + + ) : !rows.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/pages/overview.tsx b/dashboard/src/pages/overview.tsx new file mode 100644 index 0000000..8407996 --- /dev/null +++ b/dashboard/src/pages/overview.tsx @@ -0,0 +1,72 @@ +import { LayoutDashboard } from "lucide-preact"; +import { useRef } from "preact/hooks"; +import { route } from "preact-router"; +import type { Job, QueueStats } from "../api/types"; +import { ThroughputChart } from "../charts/throughput-chart"; +import { Badge } from "../components/ui/badge"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { Loading } from "../components/ui/loading"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { StatsGrid } from "../components/ui/stats-grid"; +import { useApi } from "../hooks/use-api"; +import { refreshInterval } from "../hooks/use-auto-refresh"; +import { fmtTime, truncateId } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +const JOB_COLUMNS: Column[] = [ + { + header: "ID", + accessor: (j) => {truncateId(j.id)}, + }, + { header: "Task", accessor: "task_name" }, + { header: "Queue", accessor: "queue" }, + { header: "Status", accessor: (j) => }, + { header: "Progress", accessor: (j) => }, + { header: "Created", accessor: (j) => {fmtTime(j.created_at)} }, +]; + +export function Overview(_props: RoutableProps) { + const { data: stats, loading: statsLoading } = useApi("/api/stats"); + const { data: jobs } = useApi("/api/jobs?limit=10"); + + const prevCompleted = useRef(0); + const history = useRef([]); + + if (stats) { + const completed = stats.completed || 0; + const ms = refreshInterval.value || 5000; + let throughput = 0; + if (prevCompleted.current > 0) { + throughput = parseFloat(((completed - prevCompleted.current) / (ms / 1000)).toFixed(1)); + } + prevCompleted.current = completed; + history.current = [...history.current.slice(-59), throughput]; + } + + if (statsLoading && !stats) return ; + + return ( +
+
+
+ +
+
+

Overview

+

Real-time queue status

+
+
+ + {stats && } + + +
+

Recent Jobs

+ (latest 10) +
+ {jobs?.length ? ( + route(`/jobs/${j.id}`)} /> + ) : null} +
+ ); +} diff --git a/dashboard/src/pages/queues.tsx b/dashboard/src/pages/queues.tsx new file mode 100644 index 0000000..7c84ce5 --- /dev/null +++ b/dashboard/src/pages/queues.tsx @@ -0,0 +1,106 @@ +import { Layers, Pause, Play } from "lucide-preact"; +import { apiPost } from "../api/client"; +import type { QueueStatsMap } from "../api/types"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import { addToast } from "../hooks/use-toast"; +import type { RoutableProps } from "../lib/routes"; + +interface QueueRow { + name: string; + pending: number; + running: number; + paused: boolean; +} + +export function Queues(_props: RoutableProps) { + const { data: queueStats, loading, refetch } = useApi("/api/stats/queues"); + const { data: pausedQueues, refetch: refetchPaused } = useApi("/api/queues/paused"); + + const pausedSet = new Set(pausedQueues ?? []); + + const rows: QueueRow[] = queueStats + ? Object.entries(queueStats).map(([name, s]) => ({ + name, + pending: s.pending ?? 0, + running: s.running ?? 0, + paused: pausedSet.has(name), + })) + : []; + + const handlePause = async (name: string) => { + try { + await apiPost(`/api/queues/${encodeURIComponent(name)}/pause`); + addToast(`Queue "${name}" paused`, "success"); + refetch(); + refetchPaused(); + } catch { + addToast(`Failed to pause queue "${name}"`, "error"); + } + }; + + const handleResume = async (name: string) => { + try { + await apiPost(`/api/queues/${encodeURIComponent(name)}/resume`); + addToast(`Queue "${name}" resumed`, "success"); + refetch(); + refetchPaused(); + } catch { + addToast(`Failed to resume queue "${name}"`, "error"); + } + }; + + const columns: Column[] = [ + { header: "Queue", accessor: (r) => {r.name} }, + { + header: "Pending", + accessor: (r) => {r.pending}, + }, + { + header: "Running", + accessor: (r) => {r.running}, + }, + { header: "Status", accessor: (r) => }, + { + header: "Actions", + accessor: (r) => + r.paused ? ( + + ) : ( + + ), + }, + ]; + + if (loading && !queueStats) return ; + + return ( +
+
+
+ +
+
+

Queue Management

+

Monitor and control individual queues

+
+
+ + {!rows.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/pages/resources.tsx b/dashboard/src/pages/resources.tsx new file mode 100644 index 0000000..6a0a6d2 --- /dev/null +++ b/dashboard/src/pages/resources.tsx @@ -0,0 +1,76 @@ +import { Box } from "lucide-preact"; +import type { ResourceStatus } from "../api/types"; +import { Badge } from "../components/ui/badge"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import type { RoutableProps } from "../lib/routes"; + +const RESOURCE_COLUMNS: Column[] = [ + { header: "Name", accessor: (r) => {r.name} }, + { header: "Scope", accessor: (r) => }, + { header: "Health", accessor: (r) => }, + { + header: "Init (ms)", + accessor: (r) => {r.init_duration_ms.toFixed(1)}ms, + }, + { + header: "Recreations", + accessor: (r) => ( + 0 ? "text-warning" : "text-muted"}`}> + {r.recreations} + + ), + }, + { + header: "Dependencies", + accessor: (r) => + r.depends_on.length ? ( + {r.depends_on.join(", ")} + ) : ( + {"\u2014"} + ), + }, + { + header: "Pool", + accessor: (r) => + r.pool ? ( + + {r.pool.active}/{r.pool.size} active,{" "} + {r.pool.idle} idle + + ) : ( + {"\u2014"} + ), + }, +]; + +export function Resources(_props: RoutableProps) { + const { data: resources, loading } = useApi("/api/resources"); + + if (loading && !resources) return ; + + return ( +
+
+
+ +
+
+

Resources

+

Worker dependency injection runtime

+
+
+ + {!resources?.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/pages/system.tsx b/dashboard/src/pages/system.tsx new file mode 100644 index 0000000..1eb7c7b --- /dev/null +++ b/dashboard/src/pages/system.tsx @@ -0,0 +1,110 @@ +import { Cog } from "lucide-preact"; +import type { InterceptionStats, ProxyStats } from "../api/types"; +import { type Column, DataTable } from "../components/ui/data-table"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import type { RoutableProps } from "../lib/routes"; + +interface ProxyRow { + handler: string; + reconstructions: number; + avg_ms: number; + errors: number; +} + +interface InterceptionRow { + strategy: string; + count: number; + avg_ms: number; +} + +const PROXY_COLUMNS: Column[] = [ + { header: "Handler", accessor: (r) => {r.handler} }, + { + header: "Reconstructions", + accessor: (r) => {r.reconstructions}, + }, + { + header: "Avg (ms)", + accessor: (r) => {r.avg_ms.toFixed(1)}ms, + }, + { + header: "Errors", + accessor: (r) => ( + 0 ? "text-danger font-medium" : "text-muted"}`}> + {r.errors} + + ), + }, +]; + +const INTERCEPTION_COLUMNS: Column[] = [ + { + header: "Strategy", + accessor: (r) => {r.strategy}, + }, + { header: "Count", accessor: (r) => {r.count} }, + { + header: "Avg (ms)", + accessor: (r) => {r.avg_ms.toFixed(1)}ms, + }, +]; + +export function System(_props: RoutableProps) { + const { data: proxyStats, loading: proxyLoading } = useApi("/api/proxy-stats"); + const { data: interceptionStats, loading: interceptLoading } = + useApi("/api/interception-stats"); + + const proxyRows: ProxyRow[] = proxyStats + ? Object.entries(proxyStats).map(([handler, s]) => ({ handler, ...s })) + : []; + + const interceptRows: InterceptionRow[] = interceptionStats + ? Object.entries(interceptionStats).map(([strategy, s]) => ({ strategy, ...s })) + : []; + + return ( +
+
+
+ +
+
+

System Internals

+

Proxy reconstruction and interception metrics

+
+
+ +
+

+ Proxy Reconstruction +

+ {proxyLoading && !proxyStats ? ( + + ) : !proxyRows.length ? ( + + ) : ( + + )} +
+ +
+

Interception

+ {interceptLoading && !interceptionStats ? ( + + ) : !interceptRows.length ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/dashboard/src/pages/workers.tsx b/dashboard/src/pages/workers.tsx new file mode 100644 index 0000000..29c749b --- /dev/null +++ b/dashboard/src/pages/workers.tsx @@ -0,0 +1,70 @@ +import { Clock, Server, Tag } from "lucide-preact"; +import type { QueueStats, Worker as WorkerType } from "../api/types"; +import { EmptyState } from "../components/ui/empty-state"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; +import { fmtTime } from "../lib/format"; +import type { RoutableProps } from "../lib/routes"; + +export function Workers(_props: RoutableProps) { + const { data: workers, loading } = useApi("/api/workers"); + const { data: stats } = useApi("/api/stats"); + + if (loading && !workers) return ; + + return ( +
+
+
+ +
+
+

Workers

+

+ {workers?.length ?? 0} active {"\u00b7"} {stats?.running ?? 0} running jobs +

+
+
+ + {!workers?.length ? ( + + ) : ( +
+ {workers.map((w) => ( +
+
+ + {w.worker_id} +
+
+
+ + Queues: {w.queues} +
+
+ + Last heartbeat:{" "} + {fmtTime(w.last_heartbeat)} +
+
+ + Registered:{" "} + {fmtTime(w.registered_at)} +
+ {w.tags && ( +
+ + Tags: {w.tags} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..a02ba92 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } + }, + "include": ["src"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..d0ace0d --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import preact from "@preact/preset-vite"; +import tailwindcss from "@tailwindcss/vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [preact(), tailwindcss(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + proxy: { + "/api": "http://localhost:8080", + "/health": "http://localhost:8080", + "/readiness": "http://localhost:8080", + "/metrics": "http://localhost:8080", + }, + }, +}); diff --git a/docs/changelog.md b/docs/changelog.md index 5b15534..e02f1dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,10 +6,13 @@ All notable changes to taskito are documented here. ### Features +- **Dashboard rebuild** -- full rewrite of the web dashboard using Preact, Vite, and Tailwind CSS; production-grade dark/light UI with lucide icons, toast notifications, loading states, timeseries charts, and 3 new pages (Resources, Queue Management, System Internals); 128KB single-file HTML (32KB gzipped) served from the Python package with zero runtime dependencies - **Smart scheduling** -- adaptive backpressure polling (50ms base → 200ms max backoff when idle, instant reset on dispatch); per-task duration cache tracks average execution time in-memory; weighted least-loaded dispatch for prefork pool factors in task duration (`score = in_flight × avg_duration`) ### Internal +- Dashboard frontend source in `dashboard/` (Preact + Vite + Tailwind CSS + TypeScript); build via `cd dashboard && npm run build`; output inlined into `py_src/taskito/templates/dashboard.html` +- `dashboard.py` simplified to read single pre-built HTML instead of composing from 8 separate template files - `Scheduler::run()` uses adaptive polling with exponential backoff (50ms → 200ms max); `tick()` returns `bool` for feedback - `TaskDurationCache` in-memory HashMap tracks per-task avg wall_time_ns, updated on every `handle_result()` - `weighted_least_loaded()` dispatch strategy in `prefork/dispatch.rs`; `aging_factor` field added to `SchedulerConfig` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index fa50d3c..3ee1969 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -99,6 +99,18 @@ taskito info --app tasks:queue taskito info --app tasks:queue --watch ``` +### Web Dashboard + +For a full visual interface with job browsing, metrics charts, dead letter management, and queue controls: + +```bash +taskito dashboard --app tasks:queue +``` + +Open `http://localhost:8080` in your browser. The dashboard includes 11 pages covering every aspect of your task queue — no extra dependencies needed. + +[:octicons-arrow-right-24: Dashboard guide](../guide/dashboard.md) + ## Next Steps - [Tasks](../guide/tasks.md) — decorator options, `.delay()` vs `.apply_async()` diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 1af5491..325b207 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -1,6 +1,6 @@ # Web Dashboard -taskito ships with a built-in web dashboard for monitoring jobs, inspecting dead letters, and managing your task queue in real time. The dashboard is a single-page application served directly from the Rust core -- **zero extra dependencies required**. +taskito ships with a built-in web dashboard for monitoring jobs, inspecting dead letters, and managing your task queue in real time. The dashboard is a single-page application served directly from the Python package -- **zero extra dependencies required**. ## Launching the Dashboard @@ -47,29 +47,213 @@ taskito dashboard --app myapp:queue --host 0.0.0.0 --port 9000 taskito dashboard --app myapp:queue ``` -## Dashboard Preview +## Dashboard Features -*The dashboard is a self-contained SPA served directly from the Rust core. No external dependencies required.* +The dashboard is built with Preact, Tailwind CSS, and TypeScript, compiled into a single self-contained HTML file. -## SPA Features +### Design -The dashboard is a self-contained single-page application with: +- **Dark and light mode** -- Toggle between themes via the sun/moon button in the header. Preference is stored in `localStorage` and persists across sessions. +- **Auto-refresh** -- Configurable refresh interval (2s, 5s, 10s, or off) via the header dropdown. All pages auto-refresh at the selected interval. +- **Icons** -- Lucide icons throughout for visual clarity — every nav item, stat card, and action button has a meaningful icon. +- **Toast notifications** -- Every action (cancel, retry, replay, pause, resume, purge) shows a success or error toast in the bottom-right corner. Toasts auto-dismiss after 3 seconds. +- **Loading states** -- Spinners while data loads, skeleton screens for tables and cards. +- **Responsive layout** -- Sidebar navigation with grouped sections (Monitoring, Infrastructure, Advanced). The main content area scrolls independently. -- **Dark mode** -- Toggle between light and dark themes. Preference is stored in `localStorage`. -- **Auto-refresh** -- Job stats and tables refresh automatically every 2 seconds. Disable with the pause button. -- **Status badges** -- Color-coded pills for each job status: :material-clock-outline: pending, :material-play: running, :material-check: completed, :material-alert: failed, :material-skull: dead, :material-cancel: cancelled. -- **Pagination** -- All job and dead letter tables are paginated for large queues. -- **Job detail view** -- Click any job to see its full payload, error history, retry count, progress, and metadata. -- **Dead letter management** -- Inspect, retry, or purge dead letters directly from the UI. +### Pages + +| Page | Description | +|---|---| +| **Overview** | Stats cards with status icons, throughput sparkline chart, recent jobs table | +| **Jobs** | Filterable job listing (status, queue, task, metadata, error, date range) with pagination | +| **Job Detail** | Full job info, error history, task logs, replay history, dependency DAG visualization | +| **Metrics** | Per-task performance table (avg, P50, P95, P99) with timeseries chart and time range selector | +| **Logs** | Structured task execution logs with task/level filters | +| **Workers** | Worker cards with heartbeat status, queue assignments, and tags | +| **Queues** | Per-queue stats (pending/running), pause and resume controls | +| **Resources** | Worker DI runtime status -- health, scope, init duration, pool stats, dependencies | +| **Circuit Breakers** | Automatic failure protection state (closed/open/half_open), thresholds, cooldowns | +| **Dead Letters** | Failed jobs that exhausted retries -- retry individual entries or purge all | +| **System** | Proxy reconstruction and interception strategy metrics | !!! info "Zero extra dependencies" - The SPA is embedded directly in the Python package. No Node.js, no npm, no CDN -- just `pip install taskito`. + The SPA is embedded directly in the Python package. No Node.js, no npm, no CDN -- just `pip install taskito`. Node.js is only needed by contributors who modify the dashboard source. + +## Tutorial + +This walkthrough covers every dashboard page and how to use it. + +### Step 1: Start the Dashboard + +Start a worker and the dashboard in two terminals: + +```bash +# Terminal 1 — start the worker +taskito worker --app myapp:queue + +# Terminal 2 — start the dashboard +taskito dashboard --app myapp:queue +``` + +You should see: + +``` +taskito dashboard → http://127.0.0.1:8080 +Press Ctrl+C to stop +``` + +Open `http://localhost:8080` in your browser. + +### Step 2: Overview Page + +The first page you see is the **Overview**. It shows: + +- **Stats cards** -- Six cards at the top showing pending, running, completed, failed, dead, and cancelled job counts. Each card has a colored icon matching its status. +- **Throughput chart** -- A green sparkline showing jobs processed per second over the last 60 refresh intervals. The current throughput value is displayed in the top-right. +- **Recent jobs table** -- The 10 most recent jobs. Click any row to open its detail view. + +The stats update automatically based on the refresh interval you select in the header (default: 5 seconds). + +### Step 3: Browsing and Filtering Jobs + +Click **Jobs** in the sidebar. This page shows: + +- **Stats grid** -- Same six stat cards as the overview. +- **Filter panel** -- A card with seven filter fields: + - **Status dropdown** -- Filter by pending, running, complete, failed, dead, or cancelled. + - **Queue** -- Text input to filter by queue name. + - **Task** -- Text input to filter by task name. + - **Metadata** -- Search within job metadata (uses SQL LIKE). + - **Error text** -- Search within error messages. + - **Created after / before** -- Date pickers for time-range filtering. +- **Results table** -- Paginated list showing ID, task, queue, status, priority, progress, retries, and created time. Click any row to see the full job detail. + +Use the **Prev / Next** buttons at the bottom to paginate. The current page range is shown (e.g., "Showing 1–20 items"). + +### Step 4: Inspecting a Job + +Click any job row to open the **Job Detail** page. The detail card shows: + +- A colored top border matching the job status (green for complete, red for failed, etc.) +- Full job ID, status badge, task name, queue, priority, progress bar, retries, timestamps +- **Error** field (if the job failed) displayed in a red-highlighted box +- Unique key and metadata (if set) + +**Actions:** + +- **Cancel Job** -- Visible only for pending jobs. Sends a cancel request and shows a toast. +- **Replay** -- Re-enqueue the job with the same payload. Navigates to the new job's detail page. + +**Sections below the detail card:** + +- **Error History** -- One row per failed attempt, showing the attempt number, error message, and timestamp. +- **Task Logs** -- Structured log entries emitted during task execution (time, level, message, extra data). +- **Replay History** -- If the job has been replayed, shows each replay with the original and replay errors. +- **Dependency Graph** -- If the job has dependencies, renders an SVG visualization with colored nodes (click a node to navigate to that job). + +Click **← Back to jobs** at the bottom to return. + +### Step 5: Monitoring Metrics + +Click **Metrics** in the sidebar. This page shows: + +- **Time range selector** -- Three buttons in the top-right: **1h**, **6h**, **24h**. Controls the lookback window for both the chart and the table. +- **Timeseries chart** -- Stacked bar chart showing success (green) and failure (red) counts per time bucket. X-axis shows timestamps, Y-axis shows job counts. +- **Per-task table** -- One row per task name with columns: + - Total, Success (green), Failures (red if > 0) + - Avg, P50, P95, P99, Min, Max latency — color-coded green/yellow/red based on thresholds + +### Step 6: Viewing Logs + +Click **Logs** in the sidebar. This page shows structured task execution logs from the last hour: + +- **Filter by task** -- Text input to narrow logs to a specific task name. +- **Filter by level** -- Dropdown to show only error, warning, info, or debug logs. +- **Log table** -- Time, level badge (colored), task name, job ID (clickable link), message, and extra data. + +### Step 7: Checking Workers + +Click **Workers** in the sidebar. Each active worker is displayed as a card showing: + +- **Green dot** -- Indicates the worker is alive. +- **Worker ID** -- The unique identifier in monospace text. +- **Queues** -- Which queues the worker consumes from. +- **Last heartbeat** -- When the worker last reported in. If this is stale (many seconds old), the worker may be hung or dead. +- **Registered at** -- When the worker connected. +- **Tags** -- Custom worker tags (if configured). + +The header shows the total number of active workers and currently running jobs. + +### Step 8: Managing Queues + +Click **Queues** in the sidebar. This page shows: + +- **Per-queue table** -- Each queue with its pending count (yellow), running count (blue), and status badge. +- **Pause button** -- Pauses the queue so no new jobs are dequeued. Workers currently running jobs on that queue will finish, but no new work starts. A toast confirms the action. +- **Resume button** -- Resumes a paused queue. Processing starts again immediately. + +!!! note "What pausing does" + Pausing a queue prevents the scheduler from dequeuing new jobs from it. Jobs already running will complete normally. Enqueuing new jobs still works — they'll be picked up when the queue is resumed. + +### Step 9: Inspecting Resources + +Click **Resources** in the sidebar. This page shows the worker dependency injection runtime: + +- **Name** -- The resource name as registered with `@queue.worker_resource()`. +- **Scope** -- WORKER, TASK, THREAD, or REQUEST. +- **Health** -- Badge showing healthy (green), unhealthy (red), or degraded (yellow). +- **Init (ms)** -- How long the resource took to initialize. +- **Recreations** -- Number of times the resource was re-created (e.g., after a health check failure). +- **Dependencies** -- Other resources this one depends on. +- **Pool** -- For pooled resources: active/total count and idle workers. + +### Step 10: Circuit Breakers + +Click **Circuit Breakers** in the sidebar. Each circuit breaker shows: + +- **State** -- Badge: closed (green, normal), open (red, tripping), or half_open (yellow, testing recovery). +- **Failure count** -- Current failures within the window. +- **Threshold** -- Failures needed to trip open. +- **Window / Cooldown** -- Time windows for failure counting and recovery. + +### Step 11: Dead Letter Queue + +Click **Dead Letters** in the sidebar. This page shows jobs that failed all retry attempts: + +- **Retry button** -- Re-enqueue an individual dead letter as a new job. A toast shows the result. +- **Purge All** -- Button in the top-right header. Opens a confirmation dialog before deleting all dead letters permanently. +- **Error column** -- Shows the final error message (truncated, hover for full text). +- **Original Job** -- Clickable link to the job that originally failed. + +### Step 12: System Internals + +Click **System** in the sidebar. Two tables: + +- **Proxy Reconstruction** -- Per-handler metrics for non-serializable object proxying (reconstructions, average duration, errors). +- **Interception** -- Per-strategy metrics for argument interception (count, average duration). + +These are empty unless your app uses the proxy or interception features. + +### Step 13: Switching Themes + +Click the **sun icon** (in dark mode) or **moon icon** (in light mode) in the top-right of the header. The theme switches immediately and persists in `localStorage`. + +### Step 14: Changing Refresh Rate + +Use the **Refresh** dropdown in the header to change how often all data refreshes: + +- **2s** -- Near-real-time monitoring. +- **5s** -- Default, good balance. +- **10s** -- Low-frequency polling. +- **Off** -- Manual only (reload the page to refresh). ## REST API -The dashboard exposes a JSON API you can use independently of the UI. All endpoints return `application/json`. +The dashboard exposes a JSON API you can use independently of the UI. All endpoints return `application/json` with `Access-Control-Allow-Origin: *`. -### `GET /api/stats` +### Stats + +#### `GET /api/stats` Queue statistics snapshot. @@ -84,155 +268,182 @@ Queue statistics snapshot. } ``` -### `GET /api/jobs` +#### `GET /api/stats/queues` + +Per-queue statistics. Pass `?queue=name` for a single queue, or omit for all queues. + +```bash +curl http://localhost:8080/api/stats/queues +curl http://localhost:8080/api/stats/queues?queue=emails +``` + +### Jobs + +#### `GET /api/jobs` -Paginated list of jobs. +Paginated list of jobs with filtering. | Parameter | Type | Default | Description | |---|---|---|---| -| `status` | `string` | all | Filter by status: `pending`, `running`, `completed`, `failed`, `dead`, `cancelled` | -| `limit` | `int` | `50` | Page size | +| `status` | `string` | all | Filter by status | +| `queue` | `string` | all | Filter by queue name | +| `task` | `string` | all | Filter by task name | +| `metadata` | `string` | — | Search metadata (LIKE) | +| `error` | `string` | — | Search error text (LIKE) | +| `created_after` | `int` | — | Unix ms timestamp | +| `created_before` | `int` | — | Unix ms timestamp | +| `limit` | `int` | `20` | Page size | | `offset` | `int` | `0` | Pagination offset | ```bash curl http://localhost:8080/api/jobs?status=running&limit=10 ``` -### `GET /api/jobs/{id}` +#### `GET /api/jobs/{id}` -Full detail for a single job, including error history and progress. +Full detail for a single job. -```bash -curl http://localhost:8080/api/jobs/01H5K6X... -``` +#### `GET /api/jobs/{id}/errors` -```json -{ - "id": "01H5K6X...", - "task_name": "myapp.tasks.process", - "status": "completed", - "queue": "default", - "priority": 0, - "progress": 100, - "result": "\"done\"", - "retry_count": 0, - "created_at": 1700000000000, - "started_at": 1700000001000, - "completed_at": 1700000005000, - "errors": [], - "metadata": null -} -``` +Error history for a job (one entry per failed attempt). -### `GET /api/dead-letters` +#### `GET /api/jobs/{id}/logs` -Paginated list of dead letter entries. +Task execution logs for a specific job. -| Parameter | Type | Default | Description | -|---|---|---|---| -| `limit` | `int` | `50` | Page size | -| `offset` | `int` | `0` | Pagination offset | +#### `GET /api/jobs/{id}/replay-history` -```bash -curl http://localhost:8080/api/dead-letters?limit=5 -``` +Replay history for a job that has been replayed. -### `POST /api/dead-letters/{id}/retry` +#### `GET /api/jobs/{id}/dag` -Re-enqueue a dead letter job. Returns the new job ID. +Dependency graph for a job (nodes and edges). -```bash -curl -X POST http://localhost:8080/api/dead-letters/01H5K6X.../retry -``` +#### `POST /api/jobs/{id}/cancel` + +Cancel a pending job. ```json -{ - "new_job_id": "01H5K7Y..." -} +{ "cancelled": true } ``` -### `DELETE /api/dead-letters/{id}` +#### `POST /api/jobs/{id}/replay` -Delete a single dead letter entry. +Replay a completed or failed job with the same payload. -### `POST /api/jobs/{id}/cancel` +```json +{ "replay_job_id": "01H5K7Y..." } +``` -Cancel a pending job. Returns `204 No Content` on success or `409 Conflict` if the job is not in a cancellable state. +### Dead Letters -### `GET /api/jobs/{id}/logs` +#### `GET /api/dead-letters` -Task execution logs for a specific job. +Paginated list of dead letter entries. Supports `limit` and `offset` parameters. -```bash -curl http://localhost:8080/api/jobs/01H5K6X.../logs +#### `POST /api/dead-letters/{id}/retry` + +Re-enqueue a dead letter job. + +```json +{ "new_job_id": "01H5K7Y..." } ``` -### `GET /api/jobs/{id}/replay-history` +#### `POST /api/dead-letters/purge` -Replay history for a job that has been retried from the DLQ. +Purge all dead letters. -```bash -curl http://localhost:8080/api/jobs/01H5K6X.../replay-history +```json +{ "purged": 42 } ``` -### `GET /api/logs` +### Metrics -Query task execution logs across all jobs. +#### `GET /api/metrics` + +Per-task execution metrics. | Parameter | Type | Default | Description | |---|---|---|---| -| `limit` | `int` | `50` | Page size | -| `offset` | `int` | `0` | Pagination offset | +| `task` | `string` | all | Filter by task name | +| `since` | `int` | `3600` | Lookback window in seconds | -```bash -curl http://localhost:8080/api/logs?limit=20 -``` +#### `GET /api/metrics/timeseries` -### `GET /api/metrics` +Time-bucketed metrics for charts. -Task execution metrics (timing, throughput). +| Parameter | Type | Default | Description | +|---|---|---|---| +| `task` | `string` | all | Filter by task name | +| `since` | `int` | `3600` | Lookback window in seconds | +| `bucket` | `int` | `60` | Bucket size in seconds | -```bash -curl http://localhost:8080/api/metrics -``` +### Logs -### `GET /api/circuit-breakers` +#### `GET /api/logs` -Current state of all circuit breakers. +Query task execution logs across all jobs. -```bash -curl http://localhost:8080/api/circuit-breakers -``` +| Parameter | Type | Default | Description | +|---|---|---|---| +| `task` | `string` | all | Filter by task name | +| `level` | `string` | all | Filter by log level | +| `since` | `int` | `3600` | Lookback window in seconds | +| `limit` | `int` | `100` | Max entries | + +### Infrastructure -### `GET /api/workers` +#### `GET /api/workers` List registered workers with heartbeat status. -```bash -curl http://localhost:8080/api/workers -``` +#### `GET /api/circuit-breakers` -```json -[ - { - "worker_id": "worker-abc123", - "last_heartbeat": 1700000010000, - "queues": ["default", "emails"], - "status": "active" - } -] -``` +Current state of all circuit breakers. -| Field | Type | Description | -|---|---|---| -| `worker_id` | `string` | Unique worker identifier | -| `last_heartbeat` | `int` | Last heartbeat timestamp (ms) | -| `queues` | `list[string]` | Queues this worker consumes from | -| `status` | `string` | `"active"` or `"stale"` | +#### `GET /api/resources` -## Using the API Programmatically +Worker resource health and pool status. + +#### `GET /api/queues/paused` + +List paused queue names. + +#### `POST /api/queues/{name}/pause` + +Pause a queue (jobs stop being dequeued). + +#### `POST /api/queues/{name}/resume` -You can integrate the dashboard API into your own monitoring stack: +Resume a paused queue. + +### Observability + +#### `GET /api/proxy-stats` + +Per-handler proxy reconstruction metrics. + +#### `GET /api/interception-stats` + +Interception strategy performance metrics. + +#### `GET /api/scaler` + +KEDA-compatible autoscaler payload. Pass `?queue=name` for a specific queue. + +#### `GET /health` + +Liveness check. Always returns `{"status": "ok"}`. + +#### `GET /readiness` + +Readiness check with storage, worker, and resource health. + +#### `GET /metrics` + +Prometheus metrics endpoint (requires `prometheus-client` package). + +## Using the API Programmatically ```python import requests @@ -247,5 +458,84 @@ if stats["running"] > 100: print(f"WARNING: {stats['running']} jobs running, possible backlog") ``` +```python +# Pause a queue during deployment +requests.post("http://localhost:8080/api/queues/default/pause") + +# ... deploy ... + +# Resume after deployment +requests.post("http://localhost:8080/api/queues/default/resume") +``` + +```python +# Retry all dead letters +dead = requests.get("http://localhost:8080/api/dead-letters?limit=100").json() +for entry in dead: + requests.post(f"http://localhost:8080/api/dead-letters/{entry['id']}/retry") + print(f"Retried {entry['task_name']}") +``` + +## Development + +Contributors who want to modify the dashboard source: + +```bash +# Install dependencies +cd dashboard && npm install + +# Start Vite dev server (proxies /api/* to localhost:8080) +npm run dev + +# In another terminal, start the backend +taskito dashboard --app myapp:queue + +# Build and copy to Python package +npm run build +``` + +The build script compiles the Preact app into a single HTML file and copies it to `py_src/taskito/templates/dashboard.html`. This file is committed to the repository so Python-only users never need Node.js. + +### How the build works + +``` +dashboard/src/ (Preact + TypeScript) + ↓ npm run build + ↓ Vite compiles, tree-shakes, and minifies + ↓ vite-plugin-singlefile inlines all JS and CSS + ↓ Copies to py_src/taskito/templates/dashboard.html + ↓ +py_src/taskito/templates/dashboard.html (128KB self-contained) + ↓ pip install / maturin develop + ↓ Bundled in the Python wheel + ↓ +dashboard.py reads it via importlib.resources + ↓ Serves via Python stdlib http.server + ↓ +Browser loads single HTML → Preact SPA boots → fetches /api/* +``` + +### Project structure + +``` +dashboard/ +├── package.json # Dependencies: preact, @preact/signals, lucide-preact, tailwindcss +├── vite.config.ts # Vite + Preact + Tailwind + singlefile plugins, API proxy +├── tsconfig.json # TypeScript config (strict, Preact JSX) +├── index.html # Vite entry point +└── src/ + ├── main.tsx # Mount point + ├── app.tsx # Router setup (preact-router) + ├── index.css # Tailwind directives, theme tokens, animations + ├── api/ # API client and TypeScript types + ├── hooks/ # useApi, useAutoRefresh, useTheme, useToast + ├── components/ + │ ├── layout/ # Shell, Header, Sidebar + │ └── ui/ # Badge, Button, DataTable, StatCard, Toast, etc. + ├── charts/ # ThroughputChart, TimeseriesChart, DagViewer + ├── pages/ # One file per page (11 total) + └── lib/ # Format helpers, route constants +``` + !!! warning "Authentication" The dashboard does not include authentication. If you expose it beyond `localhost`, place it behind a reverse proxy with authentication (e.g. nginx with basic auth, or an OAuth2 proxy). diff --git a/py_src/taskito/dashboard.py b/py_src/taskito/dashboard.py index 03829e2..fb58009 100644 --- a/py_src/taskito/dashboard.py +++ b/py_src/taskito/dashboard.py @@ -32,24 +32,9 @@ def _read_template(path: str) -> str: return resources.files("taskito").joinpath(path).read_text(encoding="utf-8") -# JS files are loaded in dependency order: utils first, then components, -# then views/charts/actions, and finally app.js (which boots the SPA). -_JS_FILES = [ - "templates/js/utils.js", - "templates/js/components.js", - "templates/js/views.js", - "templates/js/chart.js", - "templates/js/actions.js", - "templates/js/app.js", -] - - def _load_spa_html() -> str: - """Compose the dashboard SPA from CSS, JS modules, and HTML shell.""" - html = _read_template("templates/dashboard.html") - css = _read_template("templates/dashboard.css") - js = "\n".join(_read_template(f) for f in _JS_FILES) - return html.replace("/* __TASKITO_CSS__ */", css).replace("/* __TASKITO_JS__ */", js) + """Load the pre-built dashboard SPA (single-file Vite output).""" + return _read_template("templates/dashboard.html") _SPA_HTML: str | None = None diff --git a/py_src/taskito/templates/dashboard.css b/py_src/taskito/templates/dashboard.css deleted file mode 100644 index a38c616..0000000 --- a/py_src/taskito/templates/dashboard.css +++ /dev/null @@ -1,368 +0,0 @@ -:root { - --bg: #1a1a2e; - --bg2: #16213e; - --bg3: #0f3460; - --fg: #e0e0e0; - --fg2: #a0a0b0; - --accent: #7c4dff; - --accent2: #b388ff; - --green: #66bb6a; - --yellow: #ffa726; - --red: #ef5350; - --blue: #42a5f5; - --cyan: #26c6da; - --radius: 8px; - --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); - color: var(--fg); - min-height: 100vh; -} - -a { - color: var(--accent2); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -/* ── Header ──────────────────────────────── */ -header { - background: var(--bg2); - border-bottom: 1px solid var(--bg3); - padding: 12px 24px; - display: flex; - align-items: center; - justify-content: space-between; -} - -header h1 { - font-size: 18px; - font-weight: 600; - color: var(--accent2); -} - -header h1 span { - color: var(--fg2); - font-weight: 400; -} - -nav { - display: flex; - gap: 16px; -} - -nav a { - color: var(--fg2); - font-size: 14px; - padding: 4px 8px; - border-radius: 4px; - transition: all 0.15s; -} - -nav a:hover, -nav a.active { - color: var(--fg); - background: var(--bg3); - text-decoration: none; -} - -.refresh-ctl { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--fg2); -} - -.refresh-ctl select { - background: var(--bg3); - color: var(--fg); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 2px 6px; - font-size: 12px; -} - -/* ── Main ────────────────────────────────── */ -main { - max-width: 1200px; - margin: 0 auto; - padding: 24px; -} - -/* ── Stats cards ─────────────────────────── */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 12px; - margin-bottom: 24px; -} - -.stat-card { - background: var(--bg2); - border-radius: var(--radius); - padding: 16px; - box-shadow: var(--shadow); - text-align: center; -} - -.stat-card .value { - font-size: 28px; - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.stat-card .label { - font-size: 12px; - color: var(--fg2); - text-transform: uppercase; - margin-top: 4px; -} - -.stat-card.pending .value { color: var(--yellow); } -.stat-card.running .value { color: var(--blue); } -.stat-card.completed .value { color: var(--green); } -.stat-card.failed .value { color: var(--red); } -.stat-card.dead .value { color: var(--red); } -.stat-card.cancelled .value { color: var(--fg2); } - -/* ── Filters ─────────────────────────────── */ -.filters { - display: flex; - gap: 10px; - margin-bottom: 16px; - flex-wrap: wrap; - align-items: center; -} - -.filters select, -.filters input { - background: var(--bg2); - color: var(--fg); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 6px 10px; - font-size: 13px; -} - -.filters input { width: 180px; } - -/* ── Table ───────────────────────────────── */ -.table-wrap { - background: var(--bg2); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow: hidden; -} - -table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -thead th { - text-align: left; - padding: 10px 12px; - background: var(--bg3); - color: var(--fg2); - font-weight: 600; - font-size: 11px; - text-transform: uppercase; - white-space: nowrap; -} - -tbody td { - padding: 8px 12px; - border-top: 1px solid rgba(255, 255, 255, 0.04); - white-space: nowrap; -} - -tbody tr:hover { background: rgba(124, 77, 255, 0.06); } - -.id-cell { - font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 12px; - max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Status badges ───────────────────────── */ -.badge { - display: inline-block; - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; -} - -.badge.pending { background: rgba(255, 167, 38, 0.15); color: var(--yellow); } -.badge.running { background: rgba(66, 165, 245, 0.15); color: var(--blue); } -.badge.complete { background: rgba(102, 187, 106, 0.15); color: var(--green); } -.badge.failed { background: rgba(239, 83, 80, 0.15); color: var(--red); } -.badge.dead { background: rgba(239, 83, 80, 0.25); color: var(--red); } -.badge.cancelled { background: rgba(160, 160, 176, 0.15); color: var(--fg2); } - -/* ── Progress bar ────────────────────────── */ -.progress-bar { - width: 60px; - height: 6px; - background: rgba(255, 255, 255, 0.08); - border-radius: 3px; - overflow: hidden; - display: inline-block; - vertical-align: middle; -} - -.progress-bar .fill { - height: 100%; - background: var(--accent); - border-radius: 3px; - transition: width 0.3s; -} - -/* ── Pagination ──────────────────────────── */ -.pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - font-size: 13px; - color: var(--fg2); -} - -.pagination button { - background: var(--bg3); - color: var(--fg); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 6px 14px; - cursor: pointer; - font-size: 13px; -} - -.pagination button:disabled { opacity: 0.4; cursor: default; } -.pagination button:hover:not(:disabled) { background: var(--accent); } - -/* ── Job detail panel ────────────────────── */ -.detail-panel { - background: var(--bg2); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 20px; -} - -.detail-panel h2 { font-size: 16px; margin-bottom: 16px; } - -.detail-grid { - display: grid; - grid-template-columns: 140px 1fr; - gap: 8px 16px; - font-size: 13px; -} - -.detail-grid .label { color: var(--fg2); } - -.detail-grid .value { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - word-break: break-all; -} - -.btn { - background: var(--accent); - color: #fff; - border: none; - border-radius: 4px; - padding: 6px 16px; - cursor: pointer; - font-size: 13px; - margin-top: 12px; -} - -.btn:hover { opacity: 0.85; } -.btn.danger { background: var(--red); } - -/* ── Error list ──────────────────────────── */ -.error-list { margin-top: 16px; } - -.error-item { - background: rgba(239, 83, 80, 0.08); - border-left: 3px solid var(--red); - padding: 10px 12px; - margin-bottom: 8px; - border-radius: 0 4px 4px 0; - font-size: 13px; -} - -.error-item .attempt { color: var(--fg2); font-size: 11px; } - -/* ── Empty state ─────────────────────────── */ -.empty { - text-align: center; - padding: 48px 0; - color: var(--fg2); - font-size: 14px; -} - -/* ── Throughput ───────────────────────────── */ -.throughput { font-size: 12px; color: var(--fg2); margin-bottom: 16px; } -.throughput span { color: var(--green); font-weight: 600; } - -/* ── Chart ────────────────────────────────── */ -.chart-container { - background: var(--bg2); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 16px; - margin-bottom: 24px; -} - -.chart-container h3 { font-size: 14px; color: var(--fg2); margin-bottom: 12px; } -.chart-container canvas { width: 100%; height: 160px; } - -/* ── DAG ──────────────────────────────────── */ -.dag-container { margin-top: 16px; } -.dag-container svg { width: 100%; min-height: 120px; } -.dag-node { cursor: pointer; } -.dag-node rect { rx: 6; ry: 6; } -.dag-node text { font-size: 11px; fill: var(--fg); } -.dag-edge { stroke: var(--fg2); stroke-width: 1.5; fill: none; marker-end: url(#arrow); } - -/* ── Worker cards ─────────────────────────── */ -.worker-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 12px; -} - -.worker-card { - background: var(--bg2); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 16px; -} - -.worker-card .worker-id { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: var(--accent2); - margin-bottom: 8px; -} - -.worker-card .worker-meta { font-size: 12px; color: var(--fg2); } -.worker-card .worker-meta span { color: var(--fg); } diff --git a/py_src/taskito/templates/dashboard.html b/py_src/taskito/templates/dashboard.html index 2479b15..1e530ca 100644 --- a/py_src/taskito/templates/dashboard.html +++ b/py_src/taskito/templates/dashboard.html @@ -1,40 +1,228 @@ - - - - - - - taskito dashboard - - - - - -
-

taskito dashboard

- -
- - -
-
- -
- - - - + + + + + + taskito dashboard + + + + +
+ diff --git a/py_src/taskito/templates/js/actions.js b/py_src/taskito/templates/js/actions.js deleted file mode 100644 index 43edfe2..0000000 --- a/py_src/taskito/templates/js/actions.js +++ /dev/null @@ -1,34 +0,0 @@ -// ── Actions ─────────────────────────────────────────── - -async function cancelJob(id) { - await apiPost(`/api/jobs/${id}/cancel`); - renderJobDetail(id); -} - -async function replayJob(id) { - const res = await apiPost(`/api/jobs/${id}/replay`); - if (res.replay_job_id) { - location.hash = `#/jobs/${res.replay_job_id}`; - } -} - -async function retryDead(id) { - await apiPost(`/api/dead-letters/${id}/retry`); - renderDeadLetters(); -} - -async function purgeAll() { - if (!confirm('Purge all dead letters?')) return; - await apiPost('/api/dead-letters/purge'); - renderDeadLetters(); -} - -function jobPage(dir) { - S.page = Math.max(0, S.page + dir); - renderJobs(); -} - -function deadPage(dir) { - S.page = Math.max(0, S.page + dir); - renderDeadLetters(); -} diff --git a/py_src/taskito/templates/js/app.js b/py_src/taskito/templates/js/app.js deleted file mode 100644 index 508e7b2..0000000 --- a/py_src/taskito/templates/js/app.js +++ /dev/null @@ -1,93 +0,0 @@ -// ── State ────────────────────────────────────────────── -const S = { - stats: {}, - jobs: [], - deadLetters: [], - jobDetail: null, - jobErrors: [], - filter: { status: '', queue: '', task: '', metadata: '', error: '', created_after: '', created_before: '' }, - page: 0, - pageSize: 20, - prevCompleted: 0, - throughput: 0, - throughputHistory: [], - refreshTimer: null, - refreshMs: 5000, -}; - -const $ = (sel) => document.querySelector(sel); -const $app = () => $('#app'); - -// ── API ──────────────────────────────────────────────── -async function api(path) { - const res = await fetch(path); - return res.json(); -} - -async function apiPost(path) { - const res = await fetch(path, { method: 'POST' }); - return res.json(); -} - -// ── Routing ──────────────────────────────────────────── -function route() { - const hash = location.hash || '#/'; - document.querySelectorAll('nav a').forEach(a => a.classList.remove('active')); - - if (hash === '#/' || hash === '#') { - $('#nav-home').classList.add('active'); - renderOverview(); - } else if (hash === '#/jobs') { - $('#nav-jobs').classList.add('active'); - S.page = 0; - renderJobs(); - } else if (hash.startsWith('#/jobs/')) { - $('#nav-jobs').classList.add('active'); - const id = hash.slice(7); - renderJobDetail(id); - } else if (hash === '#/metrics') { - $('#nav-metrics').classList.add('active'); - renderMetrics(); - } else if (hash === '#/logs') { - $('#nav-logs').classList.add('active'); - renderLogs(); - } else if (hash === '#/workers') { - $('#nav-workers').classList.add('active'); - renderWorkers(); - } else if (hash === '#/circuit-breakers') { - $('#nav-cb').classList.add('active'); - renderCircuitBreakers(); - } else if (hash === '#/dead-letters') { - $('#nav-dead').classList.add('active'); - S.page = 0; - renderDeadLetters(); - } else { - $('#nav-home').classList.add('active'); - renderOverview(); - } -} - -// ── Auto-refresh ────────────────────────────────────── -function startRefresh() { - stopRefresh(); - if (S.refreshMs > 0) { - S.refreshTimer = setInterval(() => route(), S.refreshMs); - } -} - -function stopRefresh() { - if (S.refreshTimer) { - clearInterval(S.refreshTimer); - S.refreshTimer = null; - } -} - -document.getElementById('refresh-interval').onchange = (e) => { - S.refreshMs = parseInt(e.target.value); - startRefresh(); -}; - -// ── Boot ────────────────────────────────────────────── -window.addEventListener('hashchange', route); -route(); -startRefresh(); diff --git a/py_src/taskito/templates/js/chart.js b/py_src/taskito/templates/js/chart.js deleted file mode 100644 index 327b60d..0000000 --- a/py_src/taskito/templates/js/chart.js +++ /dev/null @@ -1,150 +0,0 @@ -// ── Throughput chart ────────────────────────────────── - -function drawThroughputChart() { - const canvas = document.getElementById('throughput-chart'); - if (!canvas) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - - const data = S.throughputHistory; - if (data.length < 2) { - ctx.fillStyle = 'rgba(160,160,176,0.5)'; - ctx.font = '12px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('Collecting data...', w / 2, h / 2); - return; - } - - const max = Math.max(...data, 1); - const pad = { top: 10, right: 10, bottom: 20, left: 40 }; - const cw = w - pad.left - pad.right; - const ch = h - pad.top - pad.bottom; - - // Grid lines - ctx.strokeStyle = 'rgba(255,255,255,0.06)'; - ctx.lineWidth = 1; - for (let i = 0; i <= 4; i++) { - const y = pad.top + ch * (1 - i / 4); - ctx.beginPath(); - ctx.moveTo(pad.left, y); - ctx.lineTo(w - pad.right, y); - ctx.stroke(); - ctx.fillStyle = 'rgba(160,160,176,0.5)'; - ctx.font = '10px sans-serif'; - ctx.textAlign = 'right'; - ctx.fillText((max * i / 4).toFixed(1), pad.left - 4, y + 3); - } - - // Area fill - ctx.beginPath(); - ctx.moveTo(pad.left, pad.top + ch); - data.forEach((v, i) => { - const x = pad.left + (i / (data.length - 1)) * cw; - const y = pad.top + ch * (1 - v / max); - ctx.lineTo(x, y); - }); - ctx.lineTo(pad.left + cw, pad.top + ch); - ctx.closePath(); - ctx.fillStyle = 'rgba(102, 187, 106, 0.15)'; - ctx.fill(); - - // Line - ctx.beginPath(); - data.forEach((v, i) => { - const x = pad.left + (i / (data.length - 1)) * cw; - const y = pad.top + ch * (1 - v / max); - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - }); - ctx.strokeStyle = '#66bb6a'; - ctx.lineWidth = 2; - ctx.stroke(); -} - -// ── DAG rendering ───────────────────────────────────── - -async function renderJobDag(jobId) { - const dag = await api(`/api/jobs/${jobId}/dag`); - if (!dag.nodes || dag.nodes.length <= 1) return ''; - - const nodeW = 160, nodeH = 36, gapX = 40, gapY = 20; - const nodeMap = {}; - dag.nodes.forEach((n, i) => { nodeMap[n.id] = i; }); - - // BFS layer assignment - const adj = {}; - const inDeg = {}; - dag.nodes.forEach(n => { adj[n.id] = []; inDeg[n.id] = 0; }); - dag.edges.forEach(e => { - adj[e.from] = adj[e.from] || []; - adj[e.from].push(e.to); - inDeg[e.to] = (inDeg[e.to] || 0) + 1; - }); - - const layers = []; - const placed = new Set(); - let queue = dag.nodes.filter(n => (inDeg[n.id] || 0) === 0).map(n => n.id); - while (queue.length) { - layers.push([...queue]); - queue.forEach(id => placed.add(id)); - const next = []; - queue.forEach(id => { - (adj[id] || []).forEach(to => { - if (!placed.has(to) && !next.includes(to)) next.push(to); - }); - }); - queue = next; - } - dag.nodes.forEach(n => { if (!placed.has(n.id)) { layers.push([n.id]); placed.add(n.id); } }); - - const positions = {}; - let svgW = 0, svgH = 0; - layers.forEach((layer, li) => { - layer.forEach((id, ni) => { - const x = 20 + li * (nodeW + gapX); - const y = 20 + ni * (nodeH + gapY); - positions[id] = { x, y }; - svgW = Math.max(svgW, x + nodeW + 20); - svgH = Math.max(svgH, y + nodeH + 20); - }); - }); - - const statusColors = { - pending: '#ffa726', running: '#42a5f5', complete: '#66bb6a', - failed: '#ef5350', dead: '#ef5350', cancelled: '#a0a0b0' - }; - - let edgesSvg = dag.edges.map(e => { - const from = positions[e.from], to = positions[e.to]; - if (!from || !to) return ''; - return ``; - }).join(''); - - let nodesSvg = dag.nodes.map(n => { - const p = positions[n.id]; - if (!p) return ''; - const fill = statusColors[n.status] || '#a0a0b0'; - return ` - - - ${n.status.toUpperCase()} - ${escHtml(n.task_name.length > 18 ? n.task_name.slice(-18) : n.task_name)} - `; - }).join(''); - - return ` -
-

Dependency Graph

-
- - - ${edgesSvg} - ${nodesSvg} - -
-
`; -} diff --git a/py_src/taskito/templates/js/components.js b/py_src/taskito/templates/js/components.js deleted file mode 100644 index 3ecb9b6..0000000 --- a/py_src/taskito/templates/js/components.js +++ /dev/null @@ -1,58 +0,0 @@ -// ── Shared HTML builders ────────────────────────────── - -function statsHTML(s) { - const items = [ - ['pending', s.pending || 0], - ['running', s.running || 0], - ['completed', s.completed || 0], - ['failed', s.failed || 0], - ['dead', s.dead || 0], - ['cancelled', s.cancelled || 0], - ]; - return `
${items.map(([k, v]) => - `
${v.toLocaleString()}
${k}
` - ).join('')}
`; -} - -function jobTableHTML(jobs, paginate) { - if (!jobs.length) return '
No jobs found
'; - return ` -
- - - - - - ${jobs.map(j => ` - - - - - - - - - - `).join('')} - -
IDTaskQueueStatusPriorityProgressRetriesCreated
${escHtml(j.id.slice(0, 8))}${escHtml(j.task_name)}${escHtml(j.queue)}${badgeHTML(j.status)}${j.priority}${progressHTML(j.progress)}${j.retry_count}/${j.max_retries}${fmtTime(j.created_at)}
- ${paginate ? ` - ` : ''} -
-`; -} - -function badgeHTML(status) { - return `${status}`; -} - -function progressHTML(progress) { - if (progress == null) return ''; - return `
${progress}%`; -} diff --git a/py_src/taskito/templates/js/utils.js b/py_src/taskito/templates/js/utils.js deleted file mode 100644 index 07a5984..0000000 --- a/py_src/taskito/templates/js/utils.js +++ /dev/null @@ -1,19 +0,0 @@ -// ── Utility functions ───────────────────────────────── - -function fmtTime(ms) { - if (!ms) return '—'; - const d = new Date(ms); - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) - + ' ' + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - -function escHtml(s) { - if (s == null) return ''; - const d = document.createElement('div'); - d.textContent = String(s); - return d.innerHTML; -} - -function escAttr(s) { - return escHtml(s).replace(/"/g, '"'); -} diff --git a/py_src/taskito/templates/js/views.js b/py_src/taskito/templates/js/views.js deleted file mode 100644 index fc8480c..0000000 --- a/py_src/taskito/templates/js/views.js +++ /dev/null @@ -1,348 +0,0 @@ -// ── Overview ────────────────────────────────────────── - -async function renderOverview() { - S.stats = await api('/api/stats'); - - const completed = S.stats.completed || 0; - if (S.prevCompleted > 0) { - S.throughput = ((completed - S.prevCompleted) / (S.refreshMs / 1000)).toFixed(1); - } - S.prevCompleted = completed; - - S.throughputHistory.push(parseFloat(S.throughput) || 0); - if (S.throughputHistory.length > 60) S.throughputHistory.shift(); - - const recent = await api('/api/jobs?limit=10'); - - $app().innerHTML = ` - ${statsHTML(S.stats)} -
-

Throughput (jobs/s)

- -
-

Recent Jobs

- ${jobTableHTML(recent, false)} -`; - drawThroughputChart(); -} - -// ── Jobs ────────────────────────────────────────────── - -async function renderJobs() { - S.stats = await api('/api/stats'); - - let url = `/api/jobs?limit=${S.pageSize}&offset=${S.page * S.pageSize}`; - if (S.filter.status) url += `&status=${S.filter.status}`; - if (S.filter.queue) url += `&queue=${encodeURIComponent(S.filter.queue)}`; - if (S.filter.task) url += `&task=${encodeURIComponent(S.filter.task)}`; - if (S.filter.metadata) url += `&metadata=${encodeURIComponent(S.filter.metadata)}`; - if (S.filter.error) url += `&error=${encodeURIComponent(S.filter.error)}`; - if (S.filter.created_after) url += `&created_after=${new Date(S.filter.created_after).getTime()}`; - if (S.filter.created_before) url += `&created_before=${new Date(S.filter.created_before).getTime()}`; - - S.jobs = await api(url); - - $app().innerHTML = ` - ${statsHTML(S.stats)} -
- - - - - - - -
- ${jobTableHTML(S.jobs, true)} -`; - - const bindFilter = (sel, key) => { - const el = $(sel); - if (el) el.onchange = (e) => { S.filter[key] = e.target.value; S.page = 0; renderJobs(); }; - }; - bindFilter('#f-status', 'status'); - bindFilter('#f-queue', 'queue'); - bindFilter('#f-task', 'task'); - bindFilter('#f-metadata', 'metadata'); - bindFilter('#f-error', 'error'); - bindFilter('#f-after', 'created_after'); - bindFilter('#f-before', 'created_before'); -} - -// ── Job Detail ──────────────────────────────────────── - -async function renderJobDetail(id) { - const job = await api(`/api/jobs/${id}`); - if (job.error === 'Job not found') { - $app().innerHTML = `
Job not found: ${escHtml(id)}
`; - return; - } - - const errors = await api(`/api/jobs/${id}/errors`); - - $app().innerHTML = ` -
-

Job ${escHtml(id.slice(0, 8))}...

-
-
ID
${escHtml(job.id)}
-
Status
${badgeHTML(job.status)}
-
Task
${escHtml(job.task_name)}
-
Queue
${escHtml(job.queue)}
-
Priority
${job.priority}
-
Progress
${progressHTML(job.progress)}
-
Retries
${job.retry_count} / ${job.max_retries}
-
Created
${fmtTime(job.created_at)}
-
Scheduled
${fmtTime(job.scheduled_at)}
-
Started
${job.started_at ? fmtTime(job.started_at) : '—'}
-
Completed
${job.completed_at ? fmtTime(job.completed_at) : '—'}
-
Timeout
${(job.timeout_ms / 1000).toFixed(0)}s
- ${job.error ? `
Error
${escHtml(job.error)}
` : ''} - ${job.unique_key ? `
Unique Key
${escHtml(job.unique_key)}
` : ''} - ${job.metadata ? `
Metadata
${escHtml(job.metadata)}
` : ''} -
- ${job.status === 'pending' ? `` : ''} - -
- - ${errors.length ? ` -
-

Error History (${errors.length})

- ${errors.map(e => ` -
-
Attempt ${e.attempt} — ${fmtTime(e.failed_at)}
-
${escHtml(e.error)}
-
- `).join('')} -
` : ''} - - ${await renderJobLogs(id)} - ${await renderReplayHistory(id)} - ${await renderJobDag(id)} - - -`; -} - -async function renderJobLogs(jobId) { - const logs = await api(`/api/jobs/${jobId}/logs`); - if (!logs.length) return ''; - return ` -
-

Task Logs (${logs.length})

-
- - - - ${logs.map(l => ` - - - - - - `).join('')} - -
TimeLevelMessageExtra
${fmtTime(l.logged_at)}${l.level}${escHtml(l.message)}${escHtml(l.extra || '')}
-
-
`; -} - -async function renderReplayHistory(jobId) { - const history = await api(`/api/jobs/${jobId}/replay-history`); - if (!history.length) return ''; - return ` -
-

Replay History (${history.length})

-
- - - - ${history.map(h => ` - - - - - - `).join('')} - -
Replay JobReplayed AtOriginal ErrorReplay Error
${escHtml(h.replay_job_id.slice(0, 8))}${fmtTime(h.replayed_at)}${escHtml(h.original_error || '—')}${escHtml(h.replay_error || '—')}
-
-
`; -} - -// ── Dead Letters ────────────────────────────────────── - -async function renderDeadLetters() { - S.deadLetters = await api(`/api/dead-letters?limit=${S.pageSize}&offset=${S.page * S.pageSize}`); - - $app().innerHTML = ` -

Dead Letter Queue

- ${S.deadLetters.length === 0 ? '
No dead letters
' : ` -
- - - - - - ${S.deadLetters.map(d => ` - - - - - - - - - - - `).join('')} - -
IDOriginal JobTaskQueueErrorRetriesFailed AtActions
${escHtml(d.id.slice(0, 8))}${escHtml(d.original_job_id.slice(0, 8))}${escHtml(d.task_name)}${escHtml(d.queue)}${escHtml(d.error || '—')}${d.retry_count}${fmtTime(d.failed_at)}
- -
`} - ${S.deadLetters.length > 0 ? `` : ''} -`; -} - -// ── Metrics ─────────────────────────────────────────── - -async function renderMetrics() { - const metrics = await api('/api/metrics'); - const tasks = Object.keys(metrics); - - if (!tasks.length) { - $app().innerHTML = '

Task Metrics

No metrics yet. Run some tasks first.
'; - return; - } - - let rows = tasks.map(t => { - const m = metrics[t]; - return ` - ${escHtml(t)} - ${m.count} - ${m.success_count} - ${m.failure_count} - ${m.avg_ms}ms - ${m.p50_ms}ms - ${m.p95_ms}ms - ${m.p99_ms}ms - ${m.min_ms}ms - ${m.max_ms}ms - `; - }).join(''); - - $app().innerHTML = ` -

Task Metrics (last hour)

-
- - - - - ${rows} -
TaskTotalSuccessFailuresAvgP50P95P99MinMax
-
-`; -} - -// ── Logs ────────────────────────────────────────────── - -async function renderLogs() { - const logs = await api('/api/logs?limit=100'); - - $app().innerHTML = ` -

Task Logs (last hour)

- ${!logs.length ? '
No logs yet.
' : ` -
- - - - - - ${logs.map(l => ` - - - - - - - - `).join('')} - -
TimeLevelTaskJobMessageExtra
${fmtTime(l.logged_at)}${l.level}${escHtml(l.task_name)}${escHtml(l.job_id.slice(0, 8))}${escHtml(l.message)}${escHtml(l.extra || '')}
-
`} -`; -} - -// ── Circuit Breakers ────────────────────────────────── - -async function renderCircuitBreakers() { - const breakers = await api('/api/circuit-breakers'); - - $app().innerHTML = ` -

Circuit Breakers

- ${!breakers.length ? '
No circuit breakers configured.
' : ` -
- - - - - - ${breakers.map(b => ` - - - - - - - - - `).join('')} - -
TaskStateFailuresThresholdWindowCooldownLast Failure
${escHtml(b.task_name)}${b.state}${b.failure_count}${b.threshold}${(b.window_ms / 1000).toFixed(0)}s${(b.cooldown_ms / 1000).toFixed(0)}s${b.last_failure_at ? fmtTime(b.last_failure_at) : '—'}
-
`} -`; -} - -// ── Workers ─────────────────────────────────────────── - -async function renderWorkers() { - const workers = await api('/api/workers'); - const stats = await api('/api/stats'); - const running = stats.running || 0; - - $app().innerHTML = ` -

Workers

- ${!workers.length ? '
No active workers.
' : ` -
- Active workers: ${workers.length} -  ·  Running jobs: ${running} -
-
- ${workers.map(w => ` -
-
${escHtml(w.worker_id)}
-
- Queues: ${escHtml(w.queues)}
- Last heartbeat: ${fmtTime(w.last_heartbeat)}
- Registered: ${fmtTime(w.registered_at)} - ${w.tags ? `
Tags: ${escHtml(w.tags)}` : ''} -
-
`).join('')} -
`} -`; -} diff --git a/tests/python/test_dashboard.py b/tests/python/test_dashboard.py index a62b98b..313ce4a 100644 --- a/tests/python/test_dashboard.py +++ b/tests/python/test_dashboard.py @@ -281,4 +281,4 @@ def test_spa_html_served(dashboard_server: tuple[str, Queue, list[Any]]) -> None with urllib.request.urlopen(base) as resp: html = resp.read().decode() assert "taskito dashboard" in html - assert "" in html + assert "" in html.lower()