From 442ae1d31f3f1c6a734b4c99086991754f67374d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:49:52 +0530 Subject: [PATCH 01/11] feat: scaffold Preact + Vite + Tailwind dashboard project Set up the dashboard frontend build system with Preact, Vite, Tailwind CSS v4, and TypeScript. Includes API client, type definitions for all 30+ endpoints, data-fetching hooks with auto-refresh, theme/toast signals, and utility functions. Output is a single HTML file via vite-plugin-singlefile. --- dashboard/index.html | 12 + dashboard/package-lock.json | 2808 +++++++++++++++++++++++ dashboard/package.json | 26 + dashboard/src/api/client.ts | 30 + dashboard/src/api/types.ts | 167 ++ dashboard/src/hooks/use-api.ts | 70 + dashboard/src/hooks/use-auto-refresh.ts | 7 + dashboard/src/hooks/use-theme.ts | 23 + dashboard/src/hooks/use-toast.ts | 27 + dashboard/src/index.css | 116 + dashboard/src/lib/format.ts | 27 + dashboard/src/lib/routes.ts | 18 + dashboard/src/main.tsx | 5 + dashboard/src/vite-env.d.ts | 1 + dashboard/tsconfig.json | 22 + dashboard/vite.config.ts | 20 + 16 files changed, 3379 insertions(+) create mode 100644 dashboard/index.html create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/src/api/client.ts create mode 100644 dashboard/src/api/types.ts create mode 100644 dashboard/src/hooks/use-api.ts create mode 100644 dashboard/src/hooks/use-auto-refresh.ts create mode 100644 dashboard/src/hooks/use-theme.ts create mode 100644 dashboard/src/hooks/use-toast.ts create mode 100644 dashboard/src/index.css create mode 100644 dashboard/src/lib/format.ts create mode 100644 dashboard/src/lib/routes.ts create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/src/vite-env.d.ts create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/vite.config.ts 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..b97e346 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2808 @@ +{ + "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": { + "@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/@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..e05cb22 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,26 @@ +{ + "name": "taskito-dashboard", + "private": true, + "version": "0.10.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && cp dist/index.html ../py_src/taskito/templates/dashboard.html", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@preact/signals": "^1.3.0", + "lucide-preact": "^0.577.0", + "preact": "^10.25.0", + "preact-router": "^4.1.2" + }, + "devDependencies": { + "@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..a1bb3e3 --- /dev/null +++ b/dashboard/src/api/client.ts @@ -0,0 +1,30 @@ +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..947f004 --- /dev/null +++ b/dashboard/src/api/types.ts @@ -0,0 +1,167 @@ +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/hooks/use-api.ts b/dashboard/src/hooks/use-api.ts new file mode 100644 index 0000000..97b555b --- /dev/null +++ b/dashboard/src/hooks/use-api.ts @@ -0,0 +1,70 @@ +import { useState, useEffect, useCallback, useRef } from "preact/hooks"; +import { refreshInterval } from "./use-auto-refresh"; +import { api } from "../api/client"; + +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); + } + }) + .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..536730d --- /dev/null +++ b/dashboard/src/hooks/use-auto-refresh.ts @@ -0,0 +1,7 @@ +import { signal } from "@preact/signals"; + +export const refreshInterval = signal(5000); + +export function setRefreshInterval(ms: number): void { + refreshInterval.value = ms; +} diff --git a/dashboard/src/hooks/use-theme.ts b/dashboard/src/hooks/use-theme.ts new file mode 100644 index 0000000..86511ce --- /dev/null +++ b/dashboard/src/hooks/use-theme.ts @@ -0,0 +1,23 @@ +import { signal, effect } 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..3fc29f6 --- /dev/null +++ b/dashboard/src/hooks/use-toast.ts @@ -0,0 +1,27 @@ +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..3b94e02 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,116 @@ +@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..4aa1673 --- /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")!); 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", + }, + }, +}); From 1d530e943e080330d99a52a70245347a42e5ea2d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:50:02 +0530 Subject: [PATCH 02/11] feat: add dashboard UI components, layout, and charts App shell with sidebar navigation (lucide icons, grouped sections, active indicator), header with gradient logo, theme toggle, and refresh control. Reusable UI components: stat cards with status icons, data tables with alternating rows, badges with status dots, buttons with hover/active states, toasts with slide-in animation, confirm dialogs with backdrop blur, loading spinners, skeleton screens, and empty states. Canvas-based charts: throughput sparkline with gradient fill, timeseries stacked bar chart, SVG dependency DAG viewer. --- dashboard/src/charts/dag-viewer.tsx | 154 ++++++++++++++++++ dashboard/src/charts/throughput-chart.tsx | 115 +++++++++++++ dashboard/src/charts/timeseries-chart.tsx | 113 +++++++++++++ dashboard/src/components/layout/header.tsx | 58 +++++++ dashboard/src/components/layout/shell.tsx | 21 +++ dashboard/src/components/layout/sidebar.tsx | 113 +++++++++++++ dashboard/src/components/ui/badge.tsx | 52 ++++++ dashboard/src/components/ui/button.tsx | 36 ++++ .../src/components/ui/confirm-dialog.tsx | 41 +++++ dashboard/src/components/ui/data-table.tsx | 69 ++++++++ dashboard/src/components/ui/empty-state.tsx | 20 +++ dashboard/src/components/ui/loading.tsx | 39 +++++ dashboard/src/components/ui/pagination.tsx | 41 +++++ dashboard/src/components/ui/progress-bar.tsx | 20 +++ dashboard/src/components/ui/stat-card.tsx | 56 +++++++ dashboard/src/components/ui/stats-grid.tsx | 25 +++ dashboard/src/components/ui/toast.tsx | 38 +++++ 17 files changed, 1011 insertions(+) create mode 100644 dashboard/src/charts/dag-viewer.tsx create mode 100644 dashboard/src/charts/throughput-chart.tsx create mode 100644 dashboard/src/charts/timeseries-chart.tsx create mode 100644 dashboard/src/components/layout/header.tsx create mode 100644 dashboard/src/components/layout/shell.tsx create mode 100644 dashboard/src/components/layout/sidebar.tsx create mode 100644 dashboard/src/components/ui/badge.tsx create mode 100644 dashboard/src/components/ui/button.tsx create mode 100644 dashboard/src/components/ui/confirm-dialog.tsx create mode 100644 dashboard/src/components/ui/data-table.tsx create mode 100644 dashboard/src/components/ui/empty-state.tsx create mode 100644 dashboard/src/components/ui/loading.tsx create mode 100644 dashboard/src/components/ui/pagination.tsx create mode 100644 dashboard/src/components/ui/progress-bar.tsx create mode 100644 dashboard/src/components/ui/stat-card.tsx create mode 100644 dashboard/src/components/ui/stats-grid.tsx create mode 100644 dashboard/src/components/ui/toast.tsx diff --git a/dashboard/src/charts/dag-viewer.tsx b/dashboard/src/charts/dag-viewer.tsx new file mode 100644 index 0000000..3a1096a --- /dev/null +++ b/dashboard/src/charts/dag-viewer.tsx @@ -0,0 +1,154 @@ +import type { DagData, DagNode, JobStatus } from "../api/types"; +import { route } from "preact-router"; + +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]); + queue.forEach((id) => 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 = {}; + dag.nodes.forEach((n) => (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

+
+ + + + + + + {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..abfac73 --- /dev/null +++ b/dashboard/src/charts/throughput-chart.tsx @@ -0,0 +1,115 @@ +import { useRef, useEffect } from "preact/hooks"; +import { TrendingUp } from "lucide-preact"; + +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..0f86cd3 --- /dev/null +++ b/dashboard/src/charts/timeseries-chart.tsx @@ -0,0 +1,113 @@ +import { useRef, useEffect } 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..c23ef65 --- /dev/null +++ b/dashboard/src/components/layout/header.tsx @@ -0,0 +1,58 @@ +import { Zap, Sun, Moon, RefreshCw } from "lucide-preact"; +import { theme, toggleTheme } from "../../hooks/use-theme"; +import { + refreshInterval, + setRefreshInterval, +} from "../../hooks/use-auto-refresh"; + +export function Header() { + return ( +
+ +
+ +
+ + taskito + + +
+ +
+ {/* Refresh interval */} +
+ + +
+ +
+ + {/* Theme toggle */} + +
+
+ ); +} diff --git a/dashboard/src/components/layout/shell.tsx b/dashboard/src/components/layout/shell.tsx new file mode 100644 index 0000000..7c72b8d --- /dev/null +++ b/dashboard/src/components/layout/shell.tsx @@ -0,0 +1,21 @@ +import { 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..8d57818 --- /dev/null +++ b/dashboard/src/components/layout/sidebar.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from "preact/hooks"; +import { getCurrentUrl } from "preact-router"; +import { + LayoutDashboard, + ListTodo, + BarChart3, + ScrollText, + Server, + Layers, + Box, + ShieldAlert, + Skull, + Cog, +} from "lucide-preact"; +import type { LucideIcon } from "lucide-preact"; + +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..85321a9 --- /dev/null +++ b/dashboard/src/components/ui/button.tsx @@ -0,0 +1,36 @@ +import { 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..b859c87 --- /dev/null +++ b/dashboard/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,41 @@ +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..fd4def2 --- /dev/null +++ b/dashboard/src/components/ui/data-table.tsx @@ -0,0 +1,69 @@ +import { 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; +} + +export function DataTable({ + columns, + data, + onRowClick, + children, +}: DataTableProps) { + return ( +
+
+ + + + {columns.map((col, i) => ( + + ))} + + + + {data.map((row, ri) => ( + 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" : "" + } ${ + onRowClick + ? "cursor-pointer hover:dark:bg-accent/[0.04] hover:bg-accent/[0.02]" + : "" + }`} + > + {columns.map((col, ci) => ( + + ))} + + ))} + +
+ {col.header} +
+ {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..f18e9a7 --- /dev/null +++ b/dashboard/src/components/ui/empty-state.tsx @@ -0,0 +1,20 @@ +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..0b5081d --- /dev/null +++ b/dashboard/src/components/ui/pagination.tsx @@ -0,0 +1,41 @@ +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..9e24680 --- /dev/null +++ b/dashboard/src/components/ui/stat-card.tsx @@ -0,0 +1,56 @@ +import { fmtNumber } from "../../lib/format"; +import { + Clock, + Play, + CheckCircle2, + XCircle, + Skull, + Ban, +} from "lucide-preact"; +import type { LucideIcon } from "lucide-preact"; + +interface StatCardProps { + label: string; + value: number; + color?: string; +} + +const STAT_CONFIG: Record< + string, + { color: string; bg: string; border: string; icon: LucideIcon } +> = { + 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..b0519b3 --- /dev/null +++ b/dashboard/src/components/ui/toast.tsx @@ -0,0 +1,38 @@ +import { CheckCircle2, XCircle, Info, X } from "lucide-preact"; +import { toasts, dismissToast, type Toast } from "../../hooks/use-toast"; + +const TYPE_CONFIG: Record = { + 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 ( + + ); + })} +
+ ); +} From cd58adf8ffdbfd2a57d28d26e605e863766df1ae Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:50:15 +0530 Subject: [PATCH 03/11] feat: implement all 11 dashboard pages with routing Overview (stats grid, throughput chart, recent jobs), Jobs (7-field filter panel, paginated table), Job Detail (status-colored accent, error history, task logs, replay history, DAG), Metrics (timeseries chart, latency color-coding, time range selector), Logs (task/level filters), Workers (status dot, heartbeat cards), Circuit Breakers, Dead Letters (retry/purge with confirm dialog). Three new pages: Resources (health, pool stats, dependencies), Queue Management (per-queue stats, pause/resume with icons), System Internals (proxy reconstruction + interception metrics). --- dashboard/src/app.tsx | 35 ++++ dashboard/src/pages/circuit-breakers.tsx | 45 ++++++ dashboard/src/pages/dead-letters.tsx | 124 +++++++++++++++ dashboard/src/pages/job-detail.tsx | 194 +++++++++++++++++++++++ dashboard/src/pages/jobs.tsx | 148 +++++++++++++++++ dashboard/src/pages/logs.tsx | 72 +++++++++ dashboard/src/pages/metrics.tsx | 94 +++++++++++ dashboard/src/pages/overview.tsx | 76 +++++++++ dashboard/src/pages/queues.tsx | 100 ++++++++++++ dashboard/src/pages/resources.tsx | 58 +++++++ dashboard/src/pages/system.tsx | 87 ++++++++++ dashboard/src/pages/workers.tsx | 70 ++++++++ 12 files changed, 1103 insertions(+) create mode 100644 dashboard/src/app.tsx create mode 100644 dashboard/src/pages/circuit-breakers.tsx create mode 100644 dashboard/src/pages/dead-letters.tsx create mode 100644 dashboard/src/pages/job-detail.tsx create mode 100644 dashboard/src/pages/jobs.tsx create mode 100644 dashboard/src/pages/logs.tsx create mode 100644 dashboard/src/pages/metrics.tsx create mode 100644 dashboard/src/pages/overview.tsx create mode 100644 dashboard/src/pages/queues.tsx create mode 100644 dashboard/src/pages/resources.tsx create mode 100644 dashboard/src/pages/system.tsx create mode 100644 dashboard/src/pages/workers.tsx diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx new file mode 100644 index 0000000..a5f9b48 --- /dev/null +++ b/dashboard/src/app.tsx @@ -0,0 +1,35 @@ +import Router from "preact-router"; +import { Shell } from "./components/layout/shell"; +import { Overview } from "./pages/overview"; +import { Jobs } from "./pages/jobs"; +import { JobDetail } from "./pages/job-detail"; +import { Metrics } from "./pages/metrics"; +import { Logs } from "./pages/logs"; +import { Workers } from "./pages/workers"; +import { CircuitBreakers } from "./pages/circuit-breakers"; +import { DeadLetters } from "./pages/dead-letters"; +import { Resources } from "./pages/resources"; +import { Queues } from "./pages/queues"; +import { System } from "./pages/system"; +import { ToastContainer } from "./components/ui/toast"; + +export function App() { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/dashboard/src/pages/circuit-breakers.tsx b/dashboard/src/pages/circuit-breakers.tsx new file mode 100644 index 0000000..1e8fdc5 --- /dev/null +++ b/dashboard/src/pages/circuit-breakers.tsx @@ -0,0 +1,45 @@ +import { ShieldAlert } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { fmtTime } from "../lib/format"; +import type { CircuitBreaker as CBType } from "../api/types"; +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..7346818 --- /dev/null +++ b/dashboard/src/pages/dead-letters.tsx @@ -0,0 +1,124 @@ +import { useState } from "preact/hooks"; +import { Skull, RotateCcw, Trash2 } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Pagination } from "../components/ui/pagination"; +import { Button } from "../components/ui/button"; +import { ConfirmDialog } from "../components/ui/confirm-dialog"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { addToast } from "../hooks/use-toast"; +import { apiPost } from "../api/client"; +import { fmtTime, truncateId } from "../lib/format"; +import type { DeadLetter } from "../api/types"; +import type { RoutableProps } from "../lib/routes"; + +const PAGE_SIZE = 20; + +export function DeadLetters(_props: RoutableProps) { + const [page, setPage] = useState(0); + const [showPurge, setShowPurge] = useState(false); + + const { data: items, loading, refetch } = useApi( + `/api/dead-letters?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`, + [page], + ); + + 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 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 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 ; + + return ( +
+
+
+
+ +
+
+

Dead Letters

+

Failed jobs that exhausted all retries

+
+
+ {items && items.length > 0 && ( + + )} +
+ + {!items?.length ? ( + + ) : ( + + + + )} + + {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..163dd35 --- /dev/null +++ b/dashboard/src/pages/job-detail.tsx @@ -0,0 +1,194 @@ +import { FileText, RotateCcw } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { Badge } from "../components/ui/badge"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { Button } from "../components/ui/button"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { DagViewer } from "../charts/dag-viewer"; +import { addToast } from "../hooks/use-toast"; +import { apiPost } from "../api/client"; +import { fmtTime, truncateId } from "../lib/format"; +import { route } from "preact-router"; +import type { Job, JobError, TaskLog, ReplayEntry, DagData } from "../api/types"; +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..175dff5 --- /dev/null +++ b/dashboard/src/pages/jobs.tsx @@ -0,0 +1,148 @@ +import { useState } from "preact/hooks"; +import { ListTodo, Search } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { StatsGrid } from "../components/ui/stats-grid"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { Pagination } from "../components/ui/pagination"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { fmtTime, truncateId } from "../lib/format"; +import { route } from "preact-router"; +import type { QueueStats, Job } from "../api/types"; +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 { data: stats } = useApi("/api/stats"); + const { data: jobs, loading } = 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); + }; + + 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)} /> +
+
+ + {loading && !jobs ? ( + + ) : !jobs?.length ? ( + + ) : ( + route(`/jobs/${j.id}`)} + > + + + )} +
+ ); +} diff --git a/dashboard/src/pages/logs.tsx b/dashboard/src/pages/logs.tsx new file mode 100644 index 0000000..a0f39b5 --- /dev/null +++ b/dashboard/src/pages/logs.tsx @@ -0,0 +1,72 @@ +import { useState } from "preact/hooks"; +import { ScrollText } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { fmtTime, truncateId } from "../lib/format"; +import type { TaskLog } from "../api/types"; +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..a27e6b7 --- /dev/null +++ b/dashboard/src/pages/metrics.tsx @@ -0,0 +1,94 @@ +import { useState } from "preact/hooks"; +import { BarChart3 } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { TimeseriesChart } from "../charts/timeseries-chart"; +import type { MetricsResponse, TaskMetrics, TimeseriesBucket } from "../api/types"; +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..c5cfc63 --- /dev/null +++ b/dashboard/src/pages/overview.tsx @@ -0,0 +1,76 @@ +import { useRef } from "preact/hooks"; +import { LayoutDashboard } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { StatsGrid } from "../components/ui/stats-grid"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { ProgressBar } from "../components/ui/progress-bar"; +import { Loading } from "../components/ui/loading"; +import { ThroughputChart } from "../charts/throughput-chart"; +import { fmtTime, truncateId } from "../lib/format"; +import { route } from "preact-router"; +import type { QueueStats, Job } from "../api/types"; +import type { RoutableProps } from "../lib/routes"; +import { refreshInterval } from "../hooks/use-auto-refresh"; + +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..8656eba --- /dev/null +++ b/dashboard/src/pages/queues.tsx @@ -0,0 +1,100 @@ +import { Layers, Play, Pause } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { addToast } from "../hooks/use-toast"; +import { apiPost } from "../api/client"; +import type { QueueStatsMap } from "../api/types"; +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..f4a9a57 --- /dev/null +++ b/dashboard/src/pages/resources.tsx @@ -0,0 +1,58 @@ +import { Box } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Badge } from "../components/ui/badge"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import type { ResourceStatus } from "../api/types"; +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..f0b3e68 --- /dev/null +++ b/dashboard/src/pages/system.tsx @@ -0,0 +1,87 @@ +import { Cog } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { DataTable, type Column } from "../components/ui/data-table"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import type { ProxyStats, InterceptionStats } from "../api/types"; +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..7ed8fc8 --- /dev/null +++ b/dashboard/src/pages/workers.tsx @@ -0,0 +1,70 @@ +import { Server, Clock, Tag } from "lucide-preact"; +import { useApi } from "../hooks/use-api"; +import { Loading } from "../components/ui/loading"; +import { EmptyState } from "../components/ui/empty-state"; +import { fmtTime } from "../lib/format"; +import type { QueueStats, Worker as WorkerType } from "../api/types"; +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} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} From 5353ff61343f003c543e9c9de75a29025e56d1fe Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:51:09 +0530 Subject: [PATCH 04/11] refactor: replace vanilla JS dashboard with pre-built Preact SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify dashboard.py to read a single pre-built HTML file instead of composing from 8 separate template files. Remove old vanilla JS sources (700 lines) now replaced by the Preact build. The built dashboard.html (128KB, 32KB gzipped) is a self-contained SPA with all CSS and JS inlined via vite-plugin-singlefile. Users still get zero-dependency install — Node.js is only needed by contributors modifying the dashboard source. --- .gitignore | 3 + py_src/taskito/dashboard.py | 19 +- py_src/taskito/templates/dashboard.css | 368 ---------------------- py_src/taskito/templates/dashboard.html | 251 ++++++++++++--- py_src/taskito/templates/js/actions.js | 34 -- py_src/taskito/templates/js/app.js | 93 ------ py_src/taskito/templates/js/chart.js | 150 --------- py_src/taskito/templates/js/components.js | 58 ---- py_src/taskito/templates/js/utils.js | 19 -- py_src/taskito/templates/js/views.js | 348 -------------------- tests/python/test_dashboard.py | 2 +- 11 files changed, 218 insertions(+), 1127 deletions(-) delete mode 100644 py_src/taskito/templates/dashboard.css delete mode 100644 py_src/taskito/templates/js/actions.js delete mode 100644 py_src/taskito/templates/js/app.js delete mode 100644 py_src/taskito/templates/js/chart.js delete mode 100644 py_src/taskito/templates/js/components.js delete mode 100644 py_src/taskito/templates/js/utils.js delete mode 100644 py_src/taskito/templates/js/views.js 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/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..519809b 100644 --- a/py_src/taskito/templates/dashboard.html +++ b/py_src/taskito/templates/dashboard.html @@ -1,40 +1,213 @@ - - - - - - - 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() From 13fb5fb288fe2629ef70d1b85b12c6c38d9946a4 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:52:29 +0530 Subject: [PATCH 05/11] docs: update dashboard guide and changelog for 0.10.0 Rewrite dashboard.md to reflect the Preact + Tailwind rebuild: 11 pages documented, all 30+ REST API endpoints listed, new development section for contributors, updated feature list. Add dashboard rebuild entry to 0.10.0 changelog. --- docs/changelog.md | 3 + docs/guide/dashboard.md | 279 ++++++++++++++++++++++++---------------- 2 files changed, 174 insertions(+), 108 deletions(-) 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/guide/dashboard.md b/docs/guide/dashboard.md index 1af5491..9e8ddde 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,45 @@ 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 header button. Preference is stored in `localStorage`. +- **Auto-refresh** -- Configurable refresh interval (2s, 5s, 10s, or off) via the header dropdown. +- **Icons** -- Lucide icons throughout for visual clarity. +- **Toast notifications** -- Action feedback (cancel, retry, replay, pause, resume, purge) with auto-dismissing toasts. +- **Loading states** -- Spinners and skeleton screens during data fetches. +- **Responsive layout** -- Sidebar navigation with grouped sections, collapsible on smaller screens. -- **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. ## 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: *`. + +### Stats -### `GET /api/stats` +#### `GET /api/stats` Queue statistics snapshot. @@ -84,155 +100,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 +``` -Paginated list of jobs. +### Jobs + +#### `GET /api/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 | -### `GET /api/workers` +### Infrastructure + +#### `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` + +Resume a paused queue. + +### Observability + +#### `GET /api/proxy-stats` -You can integrate the dashboard API into your own monitoring stack: +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 +290,25 @@ if stats["running"] > 100: print(f"WARNING: {stats['running']} jobs running, possible backlog") ``` +## 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. + !!! 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). From 1c07e35c9e1701a755713a82fa2d009fe95636ea Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:02:27 +0530 Subject: [PATCH 06/11] docs: add full dashboard tutorial and quickstart reference Add step-by-step tutorial walkthrough covering all 11 dashboard pages: overview, jobs, job detail, metrics, logs, workers, queues, resources, circuit breakers, dead letters, and system internals. Include build pipeline diagram, project structure reference, and programmatic API usage examples (pause/resume, bulk retry). Add dashboard section to quickstart guide. --- docs/getting-started/quickstart.md | 12 ++ docs/guide/dashboard.md | 239 ++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 6 deletions(-) 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 9e8ddde..325b207 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -53,12 +53,12 @@ The dashboard is built with Preact, Tailwind CSS, and TypeScript, compiled into ### Design -- **Dark and light mode** -- Toggle between themes via the header button. Preference is stored in `localStorage`. -- **Auto-refresh** -- Configurable refresh interval (2s, 5s, 10s, or off) via the header dropdown. -- **Icons** -- Lucide icons throughout for visual clarity. -- **Toast notifications** -- Action feedback (cancel, retry, replay, pause, resume, purge) with auto-dismissing toasts. -- **Loading states** -- Spinners and skeleton screens during data fetches. -- **Responsive layout** -- Sidebar navigation with grouped sections, collapsible on smaller screens. +- **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. ### Pages @@ -79,6 +79,174 @@ The dashboard is built with Preact, Tailwind CSS, and TypeScript, compiled into !!! info "Zero extra dependencies" 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` with `Access-Control-Allow-Origin: *`. @@ -290,6 +458,24 @@ 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: @@ -310,5 +496,46 @@ 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). From 83328d1ec0a11c8e7f722c9b0d571e6473cd7609 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:12:19 +0530 Subject: [PATCH 07/11] chore: add biome linting, pre-commit hooks, and CI for dashboard Add Biome (Rust-based linter + formatter) for TypeScript/TSX code quality. Fix all lint errors: add type="button" to interactive buttons, add SVG title for accessibility, replace forEach with for-of where return values were discarded. New npm scripts: lint, lint:fix, format, format:check, ci. Pre-commit hooks: dashboard-lint (biome ci) and dashboard-typecheck (tsc --noEmit) trigger on dashboard/src/ changes. CI: new dashboard-lint job runs biome, typecheck, and build check. --- .github/workflows/ci.yml | 24 +++ .pre-commit-config.yaml | 14 ++ dashboard/biome.json | 39 +++++ dashboard/package-lock.json | 164 ++++++++++++++++++ dashboard/package.json | 10 +- dashboard/src/api/client.ts | 10 +- dashboard/src/api/types.ts | 8 +- dashboard/src/app.tsx | 16 +- dashboard/src/charts/dag-viewer.tsx | 27 +-- dashboard/src/charts/throughput-chart.tsx | 6 +- dashboard/src/charts/timeseries-chart.tsx | 2 +- dashboard/src/components/layout/header.tsx | 22 +-- dashboard/src/components/layout/shell.tsx | 2 +- dashboard/src/components/layout/sidebar.tsx | 14 +- dashboard/src/components/ui/button.tsx | 3 +- .../src/components/ui/confirm-dialog.tsx | 6 +- dashboard/src/components/ui/data-table.tsx | 14 +- dashboard/src/components/ui/empty-state.tsx | 4 +- dashboard/src/components/ui/pagination.tsx | 9 +- dashboard/src/components/ui/stat-card.tsx | 45 +++-- dashboard/src/components/ui/toast.tsx | 10 +- dashboard/src/hooks/use-api.ts | 9 +- dashboard/src/hooks/use-theme.ts | 2 +- dashboard/src/hooks/use-toast.ts | 6 +- dashboard/src/index.css | 35 +++- dashboard/src/pages/circuit-breakers.tsx | 27 ++- dashboard/src/pages/dead-letters.tsx | 47 +++-- dashboard/src/pages/job-detail.tsx | 80 ++++++--- dashboard/src/pages/jobs.tsx | 89 +++++++--- dashboard/src/pages/logs.tsx | 40 +++-- dashboard/src/pages/metrics.tsx | 71 ++++++-- dashboard/src/pages/overview.tsx | 24 ++- dashboard/src/pages/queues.tsx | 22 ++- dashboard/src/pages/resources.tsx | 42 +++-- dashboard/src/pages/system.tsx | 49 ++++-- dashboard/src/pages/workers.tsx | 18 +- py_src/taskito/templates/dashboard.html | 14 +- 37 files changed, 723 insertions(+), 301 deletions(-) create mode 100644 dashboard/biome.json 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/.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/package-lock.json b/dashboard/package-lock.json index b97e346..397f00b 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -14,6 +14,7 @@ "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", @@ -337,6 +338,169 @@ "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", diff --git a/dashboard/package.json b/dashboard/package.json index e05cb22..4940673 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -5,9 +5,14 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp dist/index.html ../py_src/taskito/templates/dashboard.html", + "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" + "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", @@ -16,6 +21,7 @@ "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", diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index a1bb3e3..df6d3a7 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -7,10 +7,7 @@ export class ApiError extends Error { } } -export async function api( - path: string, - signal?: AbortSignal, -): Promise { +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}`); @@ -18,10 +15,7 @@ export async function api( return res.json(); } -export async function apiPost( - path: string, - signal?: AbortSignal, -): Promise { +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}`); diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index 947f004..e65909c 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -1,10 +1,4 @@ -export type JobStatus = - | "pending" - | "running" - | "complete" - | "failed" - | "dead" - | "cancelled"; +export type JobStatus = "pending" | "running" | "complete" | "failed" | "dead" | "cancelled"; export interface QueueStats { pending: number; diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx index a5f9b48..cbe3ae6 100644 --- a/dashboard/src/app.tsx +++ b/dashboard/src/app.tsx @@ -1,17 +1,17 @@ import Router from "preact-router"; import { Shell } from "./components/layout/shell"; -import { Overview } from "./pages/overview"; -import { Jobs } from "./pages/jobs"; -import { JobDetail } from "./pages/job-detail"; -import { Metrics } from "./pages/metrics"; -import { Logs } from "./pages/logs"; -import { Workers } from "./pages/workers"; +import { ToastContainer } from "./components/ui/toast"; import { CircuitBreakers } from "./pages/circuit-breakers"; import { DeadLetters } from "./pages/dead-letters"; -import { Resources } from "./pages/resources"; +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 { ToastContainer } from "./components/ui/toast"; +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 index 3a1096a..7e2c95f 100644 --- a/dashboard/src/charts/dag-viewer.tsx +++ b/dashboard/src/charts/dag-viewer.tsx @@ -1,5 +1,5 @@ -import type { DagData, DagNode, JobStatus } from "../api/types"; import { route } from "preact-router"; +import type { DagData, DagNode, JobStatus } from "../api/types"; interface DagViewerProps { dag: DagData; @@ -41,7 +41,7 @@ export function DagViewer({ dag }: DagViewerProps) { 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)); + for (const id of queue) placed.add(id); const next: string[] = []; queue.forEach((id) => { (adj[id] || []).forEach((to) => { @@ -58,7 +58,7 @@ export function DagViewer({ dag }: DagViewerProps) { }); const nodeMap: Record = {}; - dag.nodes.forEach((n) => (nodeMap[n.id] = n)); + for (const n of dag.nodes) nodeMap[n.id] = n; const positions: Record = {}; let svgW = 0; @@ -77,7 +77,8 @@ export function DagViewer({ dag }: DagViewerProps) {

Dependency Graph

- + + Job dependency graph route(`/jobs/${n.id}`)} - > + route(`/jobs/${n.id}`)}> - + {n.status.toUpperCase()} - {n.task_name.length > 18 - ? n.task_name.slice(-18) - : n.task_name} + {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 index abfac73..ea9ec3f 100644 --- a/dashboard/src/charts/throughput-chart.tsx +++ b/dashboard/src/charts/throughput-chart.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect } from "preact/hooks"; import { TrendingUp } from "lucide-preact"; +import { useEffect, useRef } from "preact/hooks"; interface ThroughputChartProps { data: number[]; @@ -107,7 +107,9 @@ export function ThroughputChart({ data }: ThroughputChartProps) {

Throughput

- {current.toFixed(1)} jobs/s + + {current.toFixed(1)} jobs/s +
diff --git a/dashboard/src/charts/timeseries-chart.tsx b/dashboard/src/charts/timeseries-chart.tsx index 0f86cd3..f1a8ef6 100644 --- a/dashboard/src/charts/timeseries-chart.tsx +++ b/dashboard/src/charts/timeseries-chart.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; import type { TimeseriesBucket } from "../api/types"; interface TimeseriesChartProps { diff --git a/dashboard/src/components/layout/header.tsx b/dashboard/src/components/layout/header.tsx index c23ef65..bbbd569 100644 --- a/dashboard/src/components/layout/header.tsx +++ b/dashboard/src/components/layout/header.tsx @@ -1,9 +1,6 @@ -import { Zap, Sun, Moon, RefreshCw } from "lucide-preact"; +import { Moon, RefreshCw, Sun, Zap } from "lucide-preact"; +import { refreshInterval, setRefreshInterval } from "../../hooks/use-auto-refresh"; import { theme, toggleTheme } from "../../hooks/use-theme"; -import { - refreshInterval, - setRefreshInterval, -} from "../../hooks/use-auto-refresh"; export function Header() { return ( @@ -15,9 +12,7 @@ export function Header() { taskito - +
@@ -27,9 +22,7 @@ export function Header() { - 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)} /> + 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)} + />
@@ -135,12 +173,13 @@ export function Jobs(_props: RoutableProps) { ) : !jobs?.length ? ( ) : ( - route(`/jobs/${j.id}`)} - > - + route(`/jobs/${j.id}`)}> + )}
diff --git a/dashboard/src/pages/logs.tsx b/dashboard/src/pages/logs.tsx index a0f39b5..51ef323 100644 --- a/dashboard/src/pages/logs.tsx +++ b/dashboard/src/pages/logs.tsx @@ -1,12 +1,12 @@ -import { useState } from "preact/hooks"; import { ScrollText } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { DataTable, type Column } from "../components/ui/data-table"; +import { useState } from "preact/hooks"; +import type { TaskLog } from "../api/types"; import { Badge } from "../components/ui/badge"; -import { Loading } from "../components/ui/loading"; +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 { TaskLog } from "../api/types"; import type { RoutableProps } from "../lib/routes"; const LOG_COLUMNS: Column[] = [ @@ -14,7 +14,9 @@ const LOG_COLUMNS: Column[] = [ { header: "Level", accessor: (l) => ( - + ), }, { header: "Task", accessor: (l) => {l.task_name} }, @@ -38,7 +40,10 @@ export function Logs(_props: RoutableProps) { if (taskFilter) params.set("task", taskFilter); if (levelFilter) params.set("level", levelFilter); - const { data: logs, loading } = useApi(`/api/logs?${params}`, [taskFilter, 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"; @@ -56,8 +61,17 @@ export function Logs(_props: RoutableProps) {
- setTaskFilter((e.target as HTMLInputElement).value)} /> - setTaskFilter((e.target as HTMLInputElement).value)} + /> +
- {loading && !logs ? : !logs?.length ? : } + {loading && !logs ? ( + + ) : !logs?.length ? ( + + ) : ( + + )}
); } diff --git a/dashboard/src/pages/metrics.tsx b/dashboard/src/pages/metrics.tsx index a27e6b7..9b0b84a 100644 --- a/dashboard/src/pages/metrics.tsx +++ b/dashboard/src/pages/metrics.tsx @@ -1,11 +1,11 @@ -import { useState } from "preact/hooks"; import { BarChart3 } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { DataTable, type Column } from "../components/ui/data-table"; -import { Loading } from "../components/ui/loading"; -import { EmptyState } from "../components/ui/empty-state"; -import { TimeseriesChart } from "../charts/timeseries-chart"; +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 { @@ -21,14 +21,52 @@ function latencyColor(ms: number, threshold: { good: number; warn: number }): st 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: "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: "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 }, + { + header: "Max", + accessor: (r) => ( + + {r.max_ms}ms + + ), + }, ]; const TIME_RANGES = [ @@ -39,7 +77,9 @@ const TIME_RANGES = [ export function Metrics(_props: RoutableProps) { const [since, setSince] = useState(3600); - const { data: metrics, loading } = useApi(`/api/metrics?since=${since}`, [since]); + 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], @@ -64,6 +104,7 @@ export function Metrics(_props: RoutableProps) {
{TIME_RANGES.map((r) => (
- {timeseries && timeseries.length > 0 && ( - - )} + {timeseries && timeseries.length > 0 && } {loading && !metrics ? ( diff --git a/dashboard/src/pages/overview.tsx b/dashboard/src/pages/overview.tsx index c5cfc63..8407996 100644 --- a/dashboard/src/pages/overview.tsx +++ b/dashboard/src/pages/overview.tsx @@ -1,17 +1,17 @@ -import { useRef } from "preact/hooks"; import { LayoutDashboard } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { StatsGrid } from "../components/ui/stats-grid"; -import { DataTable, type Column } from "../components/ui/data-table"; +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 { ProgressBar } from "../components/ui/progress-bar"; +import { type Column, DataTable } from "../components/ui/data-table"; import { Loading } from "../components/ui/loading"; -import { ThroughputChart } from "../charts/throughput-chart"; +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 { route } from "preact-router"; -import type { QueueStats, Job } from "../api/types"; import type { RoutableProps } from "../lib/routes"; -import { refreshInterval } from "../hooks/use-auto-refresh"; const JOB_COLUMNS: Column[] = [ { @@ -65,11 +65,7 @@ export function Overview(_props: RoutableProps) { (latest 10)
{jobs?.length ? ( - route(`/jobs/${j.id}`)} - /> + route(`/jobs/${j.id}`)} /> ) : null}
); diff --git a/dashboard/src/pages/queues.tsx b/dashboard/src/pages/queues.tsx index 8656eba..7c84ce5 100644 --- a/dashboard/src/pages/queues.tsx +++ b/dashboard/src/pages/queues.tsx @@ -1,13 +1,13 @@ -import { Layers, Play, Pause } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { DataTable, type Column } from "../components/ui/data-table"; +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 { Loading } from "../components/ui/loading"; +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 { apiPost } from "../api/client"; -import type { QueueStatsMap } from "../api/types"; import type { RoutableProps } from "../lib/routes"; interface QueueRow { @@ -56,8 +56,14 @@ export function Queues(_props: RoutableProps) { const columns: Column[] = [ { header: "Queue", accessor: (r) => {r.name} }, - { header: "Pending", accessor: (r) => {r.pending} }, - { header: "Running", accessor: (r) => {r.running} }, + { + header: "Pending", + accessor: (r) => {r.pending}, + }, + { + header: "Running", + accessor: (r) => {r.running}, + }, { header: "Status", accessor: (r) => }, { header: "Actions", diff --git a/dashboard/src/pages/resources.tsx b/dashboard/src/pages/resources.tsx index f4a9a57..6a0a6d2 100644 --- a/dashboard/src/pages/resources.tsx +++ b/dashboard/src/pages/resources.tsx @@ -1,33 +1,48 @@ import { Box } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { DataTable, type Column } from "../components/ui/data-table"; +import type { ResourceStatus } from "../api/types"; import { Badge } from "../components/ui/badge"; -import { Loading } from "../components/ui/loading"; +import { type Column, DataTable } from "../components/ui/data-table"; import { EmptyState } from "../components/ui/empty-state"; -import type { ResourceStatus } from "../api/types"; +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: "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"}, + 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 + {r.pool.active}/{r.pool.size} active,{" "} + {r.pool.idle} idle - ) : {"\u2014"}, + ) : ( + {"\u2014"} + ), }, ]; @@ -49,7 +64,10 @@ export function Resources(_props: RoutableProps) {
{!resources?.length ? ( - + ) : ( )} diff --git a/dashboard/src/pages/system.tsx b/dashboard/src/pages/system.tsx index f0b3e68..1eb7c7b 100644 --- a/dashboard/src/pages/system.tsx +++ b/dashboard/src/pages/system.tsx @@ -1,9 +1,9 @@ import { Cog } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { DataTable, type Column } from "../components/ui/data-table"; -import { Loading } from "../components/ui/loading"; +import type { InterceptionStats, ProxyStats } from "../api/types"; +import { type Column, DataTable } from "../components/ui/data-table"; import { EmptyState } from "../components/ui/empty-state"; -import type { ProxyStats, InterceptionStats } from "../api/types"; +import { Loading } from "../components/ui/loading"; +import { useApi } from "../hooks/use-api"; import type { RoutableProps } from "../lib/routes"; interface ProxyRow { @@ -21,25 +21,40 @@ interface InterceptionRow { 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: "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} + 0 ? "text-danger font-medium" : "text-muted"}`}> + {r.errors} + ), }, ]; const INTERCEPTION_COLUMNS: Column[] = [ - { header: "Strategy", accessor: (r) => {r.strategy} }, + { + header: "Strategy", + accessor: (r) => {r.strategy}, + }, { header: "Count", accessor: (r) => {r.count} }, - { header: "Avg (ms)", accessor: (r) => {r.avg_ms.toFixed(1)}ms }, + { + 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 { data: interceptionStats, loading: interceptLoading } = + useApi("/api/interception-stats"); const proxyRows: ProxyRow[] = proxyStats ? Object.entries(proxyStats).map(([handler, s]) => ({ handler, ...s })) @@ -62,11 +77,16 @@ export function System(_props: RoutableProps) {
-

Proxy Reconstruction

+

+ Proxy Reconstruction +

{proxyLoading && !proxyStats ? ( ) : !proxyRows.length ? ( - + ) : ( )} @@ -77,7 +97,10 @@ export function System(_props: RoutableProps) { {interceptLoading && !interceptionStats ? ( ) : !interceptRows.length ? ( - + ) : ( )} diff --git a/dashboard/src/pages/workers.tsx b/dashboard/src/pages/workers.tsx index 7ed8fc8..29c749b 100644 --- a/dashboard/src/pages/workers.tsx +++ b/dashboard/src/pages/workers.tsx @@ -1,9 +1,9 @@ -import { Server, Clock, Tag } from "lucide-preact"; -import { useApi } from "../hooks/use-api"; -import { Loading } from "../components/ui/loading"; +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 { QueueStats, Worker as WorkerType } from "../api/types"; import type { RoutableProps } from "../lib/routes"; export function Workers(_props: RoutableProps) { @@ -37,9 +37,7 @@ export function Workers(_props: RoutableProps) { >
- - {w.worker_id} - + {w.worker_id}
@@ -48,11 +46,13 @@ export function Workers(_props: RoutableProps) {
- Last heartbeat: {fmtTime(w.last_heartbeat)} + Last heartbeat:{" "} + {fmtTime(w.last_heartbeat)}
- Registered: {fmtTime(w.registered_at)} + Registered:{" "} + {fmtTime(w.registered_at)}
{w.tags && (
diff --git a/py_src/taskito/templates/dashboard.html b/py_src/taskito/templates/dashboard.html index 519809b..cbcb617 100644 --- a/py_src/taskito/templates/dashboard.html +++ b/py_src/taskito/templates/dashboard.html @@ -4,12 +4,12 @@ taskito dashboard - - + */const Br=b("zap",[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]]);var Jr=Symbol.for("preact-signals");function Ve(){if(z>1)z--;else{var e,t=!1;for((function(){var a=Se;for(Se=void 0;a!==void 0;)a.S.v===a.v&&(a.S.i=a.i),a=a.o})();ne!==void 0;){var n=ne;for(ne=void 0,Ce++;n!==void 0;){var s=n.u;if(n.u=void 0,n.f&=-3,!(8&n.f)&&Jt(n))try{n.c()}catch(a){t||(e=a,t=!0)}n=s}}if(Ce=0,z--,t)throw e}}var y=void 0;function zt(e){var t=y;y=void 0;try{return e()}finally{y=t}}var ne=void 0,z=0,Ce=0,pt=0,Se=void 0,Me=0;function Bt(e){if(y!==void 0){var t=e.n;if(t===void 0||t.t!==y)return t={i:0,S:e,p:y.s,n:void 0,t:y,e:void 0,x:void 0,r:t},y.s!==void 0&&(y.s.n=t),y.s=t,e.n=t,32&y.f&&e.S(t),t;if(t.i===-1)return t.i=0,t.n!==void 0&&(t.n.p=t.p,t.p!==void 0&&(t.p.n=t.n),t.p=y.s,t.n=void 0,y.s.n=t,y.s=t),t}}function M(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.l=0,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}M.prototype.brand=Jr;M.prototype.h=function(){return!0};M.prototype.S=function(e){var t=this,n=this.t;n!==e&&e.e===void 0&&(e.x=n,this.t=e,n!==void 0?n.e=e:zt(function(){var s;(s=t.W)==null||s.call(t)}))};M.prototype.U=function(e){var t=this;if(this.t!==void 0){var n=e.e,s=e.x;n!==void 0&&(n.x=s,e.e=void 0),s!==void 0&&(s.e=n,e.x=void 0),e===this.t&&(this.t=s,s===void 0&&zt(function(){var a;(a=t.Z)==null||a.call(t)}))}};M.prototype.subscribe=function(e){var t=this;return Le(function(){var n=t.value,s=y;y=void 0;try{e(n)}finally{y=s}},{name:"sub"})};M.prototype.valueOf=function(){return this.value};M.prototype.toString=function(){return this.value+""};M.prototype.toJSON=function(){return this.value};M.prototype.peek=function(){var e=y;y=void 0;try{return this.value}finally{y=e}};Object.defineProperty(M.prototype,"value",{get:function(){var e=Bt(this);return e!==void 0&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(Ce>100)throw new Error("Cycle detected");(function(n){z!==0&&Ce===0&&n.l!==pt&&(n.l=pt,Se={S:n,v:n.v,i:n.i,o:Se})})(this),this.v=e,this.i++,Me++,z++;try{for(var t=this.t;t!==void 0;t=t.x)t.t.N()}finally{Ve()}}}});function pe(e,t){return new M(e,t)}function Jt(e){for(var t=e.s;t!==void 0;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function Qt(e){for(var t=e.s;t!==void 0;t=t.n){var n=t.S.n;if(n!==void 0&&(t.r=n),t.S.n=t,t.i=-1,t.n===void 0){e.s=t;break}}}function Vt(e){for(var t=e.s,n=void 0;t!==void 0;){var s=t.p;t.i===-1?(t.S.U(t),s!==void 0&&(s.n=t.n),t.n!==void 0&&(t.n.p=s)):n=t,t.S.n=t.r,t.r!==void 0&&(t.r=void 0),t=s}e.s=n}function J(e,t){M.call(this,void 0),this.x=e,this.s=void 0,this.g=Me-1,this.f=4,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}J.prototype=new M;J.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if((36&this.f)==32||(this.f&=-5,this.g===Me))return!0;if(this.g=Me,this.f|=1,this.i>0&&!Jt(this))return this.f&=-2,!0;var e=y;try{Qt(this),y=this;var t=this.x();(16&this.f||this.v!==t||this.i===0)&&(this.v=t,this.f&=-17,this.i++)}catch(n){this.v=n,this.f|=16,this.i++}return y=e,Vt(this),this.f&=-2,!0};J.prototype.S=function(e){if(this.t===void 0){this.f|=36;for(var t=this.s;t!==void 0;t=t.n)t.S.S(t)}M.prototype.S.call(this,e)};J.prototype.U=function(e){if(this.t!==void 0&&(M.prototype.U.call(this,e),this.t===void 0)){this.f&=-33;for(var t=this.s;t!==void 0;t=t.n)t.S.U(t)}};J.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;e!==void 0;e=e.x)e.t.N()}};Object.defineProperty(J.prototype,"value",{get:function(){if(1&this.f)throw new Error("Cycle detected");var e=Bt(this);if(this.h(),e!==void 0&&(e.i=this.i),16&this.f)throw this.v;return this.v}});function Qr(e,t){return new J(e,t)}function Gt(e){var t=e.m;if(e.m=void 0,typeof t=="function"){z++;var n=y;y=void 0;try{t()}catch(s){throw e.f&=-2,e.f|=8,Ge(e),s}finally{y=n,Ve()}}}function Ge(e){for(var t=e.s;t!==void 0;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,Gt(e)}function Vr(e){if(y!==this)throw new Error("Out-of-order effect");Vt(this),y=e,this.f&=-2,8&this.f&&Ge(this),Ve()}function X(e,t){this.x=e,this.m=void 0,this.s=void 0,this.u=void 0,this.f=32,this.name=t==null?void 0:t.name}X.prototype.c=function(){var e=this.S();try{if(8&this.f||this.x===void 0)return;var t=this.x();typeof t=="function"&&(this.m=t)}finally{e()}};X.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1,this.f&=-9,Gt(this),Qt(this),z++;var e=y;return y=this,Vr.bind(this,e)};X.prototype.N=function(){2&this.f||(this.f|=2,this.u=ne,ne=this)};X.prototype.d=function(){this.f|=8,1&this.f||Ge(this)};X.prototype.dispose=function(){this.d()};function Le(e,t){var n=new X(e,t);try{n.c()}catch(a){throw n.d(),a}var s=n.d.bind(n);return s[Symbol.dispose]=s,s}var ge;function ee(e,t){k[e]=t.bind(null,k[e]||function(){})}function Te(e){if(ge){var t=ge;ge=void 0,t()}ge=e&&e.S()}function Yt(e){var t=this,n=e.data,s=Yr(n);s.value=n;var a=Pe(function(){for(var i=t.__v;i=i.__;)if(i.__c){i.__c.__$f|=4;break}return t.__$u.c=function(){var c,l=t.__$u.S(),u=a.value;l(),_t(u)||((c=t.base)==null?void 0:c.nodeType)!==3?(t.__$f|=1,t.setState({})):t.base.data=u},Qr(function(){var c=s.value.value;return c===0?0:c===!0?"":c||""})},[]);return a.value}Yt.displayName="_st";Object.defineProperties(M.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:Yt},props:{configurable:!0,get:function(){return{data:this}}},__b:{configurable:!0,value:1}});ee("__b",function(e,t){if(typeof t.type=="string"){var n,s=t.props;for(var a in s)if(a!=="children"){var i=s[a];i instanceof M&&(n||(t.__np=n={}),n[a]=i,s[a]=i.peek())}}e(t)});ee("__r",function(e,t){e(t),Te();var n,s=t.__c;s&&(s.__$f&=-2,(n=s.__$u)===void 0&&(s.__$u=n=(function(a){var i;return Le(function(){i=this}),i.c=function(){s.__$f|=1,s.setState({})},i})())),Te(n)});ee("__e",function(e,t,n,s){Te(),e(t,n,s)});ee("diffed",function(e,t){Te();var n;if(typeof t.type=="string"&&(n=t.__e)){var s=t.__np,a=t.props;if(s){var i=n.U;if(i)for(var c in i){var l=i[c];l!==void 0&&!(c in s)&&(l.d(),i[c]=void 0)}else n.U=i={};for(var u in s){var d=i[u],h=s[u];d===void 0?(d=Gr(n,u,h,a),i[u]=d):d.o(h,a)}}}e(t)});function Gr(e,t,n,s){var a=t in e&&e.ownerSVGElement===void 0,i=pe(n);return{o:function(c,l){i.value=c,s=l},d:Le(function(){var c=i.value.value;s[t]!==c&&(s[t]=c,a?e[t]=c:c?e.setAttribute(t,c):e.removeAttribute(t))})}}ee("unmount",function(e,t){if(typeof t.type=="string"){var n=t.__e;if(n){var s=n.U;if(s){n.U=void 0;for(var a in s){var i=s[a];i&&i.d()}}}}else{var c=t.__c;if(c){var l=c.__$u;l&&(c.__$u=void 0,l.d())}}e(t)});ee("__h",function(e,t,n,s){(s<3||s===9)&&(t.__$f|=2),e(t,n,s)});V.prototype.shouldComponentUpdate=function(e,t){if(this.__R)return!0;var n=this.__$u,s=n&&n.s!==void 0;for(var a in t)return!0;if(this.__f||typeof this.u=="boolean"&&this.u===!0){if(!(s||2&this.__$f||4&this.__$f)||1&this.__$f)return!0}else if(!(s||4&this.__$f)||3&this.__$f)return!0;for(var i in e)if(i!=="__source"&&e[i]!==this.props[i])return!0;for(var c in this.props)if(!(c in e))return!0;return!1};function Yr(e){return Pe(function(){return pe(e)},[])}const le=pe(5e3);function Zr(e){le.value=e}const Kr=localStorage.getItem("taskito-theme"),Z=pe(Kr??"dark");Le(()=>{const e=document.documentElement;Z.value==="dark"?(e.classList.add("dark"),e.classList.remove("light")):(e.classList.remove("dark"),e.classList.add("light")),localStorage.setItem("taskito-theme",Z.value)});function Xr(){Z.value=Z.value==="dark"?"light":"dark"}function en(){return r("header",{class:"h-14 flex items-center justify-between px-6 dark:bg-surface-2/80 bg-white/80 backdrop-blur-md border-b dark:border-white/[0.06] border-slate-200 sticky top-0 z-40",children:[r("a",{href:"/",class:"flex items-center gap-2 no-underline group",children:[r("div",{class:"w-7 h-7 rounded-lg bg-gradient-to-br from-accent to-accent-light flex items-center justify-center shadow-md shadow-accent/20",children:r(Br,{class:"w-4 h-4 text-white",strokeWidth:2.5})}),r("span",{class:"text-[15px] font-semibold dark:text-white text-slate-900 tracking-tight",children:"taskito"}),r("span",{class:"text-xs text-muted font-normal hidden sm:inline",children:"dashboard"})]}),r("div",{class:"flex items-center gap-3",children:[r("div",{class:"flex items-center gap-2 text-xs",children:[r(Ir,{class:"w-3.5 h-3.5 text-muted"}),r("select",{class:"dark:bg-surface-3 bg-slate-100 dark:text-gray-300 text-slate-600 border dark:border-white/[0.06] border-slate-200 rounded-md px-2 py-1 text-xs cursor-pointer hover:dark:border-white/10 hover:border-slate-300 transition-colors",value:le.value,onChange:e=>Zr(Number(e.target.value)),children:[r("option",{value:2e3,children:"2s"}),r("option",{value:5e3,children:"5s"}),r("option",{value:1e4,children:"10s"}),r("option",{value:0,children:"Off"})]})]}),r("div",{class:"w-px h-5 dark:bg-white/[0.06] bg-slate-200"}),r("button",{type:"button",onClick:Xr,class:"p-2 rounded-lg dark:text-gray-400 text-slate-500 hover:dark:text-white hover:text-slate-900 hover:dark:bg-surface-3 hover:bg-slate-100 transition-all duration-150 border-none cursor-pointer bg-transparent",title:`Switch to ${Z.value==="dark"?"light":"dark"} mode`,children:Z.value==="dark"?r(qr,{class:"w-4 h-4"}):r(Er,{class:"w-4 h-4"})})]})]})}const tn=[{title:"Monitoring",items:[{path:"/",label:"Overview",icon:It},{path:"/jobs",label:"Jobs",icon:qt},{path:"/metrics",label:"Metrics",icon:Rt},{path:"/logs",label:"Logs",icon:Dt}]},{title:"Infrastructure",items:[{path:"/workers",label:"Workers",icon:We},{path:"/queues",label:"Queues",icon:Ot},{path:"/resources",label:"Resources",icon:At},{path:"/circuit-breakers",label:"Circuit Breakers",icon:Ht}]},{title:"Advanced",items:[{path:"/dead-letters",label:"Dead Letters",icon:Qe},{path:"/system",label:"System",icon:Ut}]}];function rn(e,t){return t==="/"?e==="/":e===t||e.startsWith(t+"/")}function nn(){const[e,t]=R(ce());return oe(()=>{const n=()=>t(ce());return addEventListener("popstate",n),addEventListener("pushstate",n),()=>{removeEventListener("popstate",n),removeEventListener("pushstate",n)}},[]),r("aside",{class:"w-60 shrink-0 border-r dark:border-white/[0.06] border-slate-200 dark:bg-surface-2/50 bg-slate-50/50 overflow-y-auto h-[calc(100vh-56px)]",children:r("nav",{class:"p-3 space-y-5 pt-4",children:tn.map(n=>r("div",{children:[r("div",{class:"px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.1em] text-muted/60",children:n.title}),r("div",{class:"space-y-0.5",children:n.items.map(s=>{const a=rn(e,s.path),i=s.icon;return r("a",{href:s.path,class:`flex items-center gap-2.5 px-3 py-2 text-[13px] rounded-lg transition-all duration-150 no-underline relative ${a?"dark:bg-accent/10 bg-accent/5 dark:text-white text-slate-900 font-medium":"dark:text-gray-400 text-slate-500 hover:dark:text-gray-200 hover:text-slate-700 hover:dark:bg-white/[0.03] hover:bg-slate-100"}`,children:[a&&r("div",{class:"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 rounded-r-full bg-accent"}),r(i,{class:`w-4 h-4 shrink-0 ${a?"text-accent":""}`,strokeWidth:a?2.2:1.8}),s.label]},s.path)})})]},n.title))})})}function sn({children:e}){return r("div",{class:"min-h-screen",children:[r(en,{}),r("div",{class:"flex",children:[r(nn,{}),r("main",{class:"flex-1 p-8 overflow-auto h-[calc(100vh-56px)]",children:r("div",{class:"max-w-[1280px] mx-auto",children:e})})]})]})}const F=pe([]);let an=0;function j(e,t="info",n=3e3){const s=String(++an);F.value=[...F.value,{id:s,message:e,type:t}],setTimeout(()=>{F.value=F.value.filter(a=>a.id!==s)},n)}function on(e){F.value=F.value.filter(t=>t.id!==e)}const cn={success:{border:"border-l-success",icon:Nt,iconColor:"text-success"},error:{border:"border-l-danger",icon:Et,iconColor:"text-danger"},info:{border:"border-l-info",icon:Nr,iconColor:"text-info"}};function ln(){const e=F.value;return e.length?r("div",{class:"fixed bottom-5 right-5 z-50 flex flex-col gap-2.5 max-w-sm",children:e.map(t=>{const n=cn[t.type],s=n.icon;return r("div",{class:`flex items-start gap-3 border-l-[3px] ${n.border} rounded-lg px-4 py-3.5 text-[13px] dark:bg-surface-2 bg-white shadow-xl dark:shadow-black/40 dark:text-gray-200 text-slate-700 animate-slide-in border dark:border-white/[0.06] border-slate-200`,role:"alert",children:[r(s,{class:`w-4.5 h-4.5 ${n.iconColor} shrink-0 mt-0.5`,strokeWidth:2}),r("span",{class:"flex-1",children:t.message}),r("button",{type:"button",onClick:()=>on(t.id),class:"text-muted hover:dark:text-white hover:text-slate-900 transition-colors border-none bg-transparent cursor-pointer p-0.5",children:r(zr,{class:"w-3.5 h-3.5"})})]},t.id)})}):null}const dn={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"},un={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"};function O({status:e}){const t=dn[e]??"bg-muted/10 text-muted border-muted/20",n=un[e]??"bg-muted";return r("span",{class:`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${t}`,children:[r("span",{class:`w-1.5 h-1.5 rounded-full ${n}`}),e]})}function L({columns:e,data:t,onRowClick:n,children:s}){return r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 overflow-hidden border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full border-collapse text-[13px]",children:[r("thead",{children:r("tr",{children:e.map((a,i)=>r("th",{class:`text-left px-4 py-3 dark:bg-surface-3/50 bg-slate-50 text-muted font-semibold text-[11px] uppercase tracking-[0.05em] whitespace-nowrap border-b dark:border-white/[0.04] border-slate-100 ${a.className??""}`,children:a.header},i))})}),r("tbody",{children:t.map((a,i)=>r("tr",{onClick:n?()=>n(a):void 0,class:`border-b dark:border-white/[0.03] border-slate-50 last:border-0 transition-colors duration-100 ${i%2===1?"dark:bg-white/[0.01] bg-slate-50/30":""} ${n?"cursor-pointer hover:dark:bg-accent/[0.04] hover:bg-accent/[0.02]":""}`,children:e.map((c,l)=>r("td",{class:`px-4 py-3 whitespace-nowrap ${c.className??""}`,children:typeof c.accessor=="function"?c.accessor(a):a[c.accessor]},l))},i))})]})}),s]})}function N({message:e,subtitle:t}){return r("div",{class:"flex flex-col items-center justify-center py-16 text-center",children:[r("div",{class:"w-12 h-12 rounded-xl dark:bg-surface-3 bg-slate-100 flex items-center justify-center mb-4",children:r(Rr,{class:"w-6 h-6 text-muted",strokeWidth:1.5})}),r("p",{class:"text-sm font-medium dark:text-gray-300 text-slate-600",children:e}),t&&r("p",{class:"text-xs text-muted mt-1",children:t})]})}function A(){return r("div",{class:"flex items-center justify-center py-20",children:r("div",{class:"flex items-center gap-3 text-muted text-sm",children:[r("div",{class:"w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin"}),r("span",{children:"Loading\\u2026"})]})})}class Zt extends Error{constructor(t,n){super(n),this.status=t}}async function hn(e,t){const n=await fetch(e,{signal:t});if(!n.ok)throw new Zt(n.status,`${n.status} ${n.statusText}`);return n.json()}async function K(e,t){const n=await fetch(e,{method:"POST",signal:t});if(!n.ok)throw new Zt(n.status,`${n.status} ${n.statusText}`);return n.json()}function $(e,t=[]){const[n,s]=R(null),[a,i]=R(!0),[c,l]=R(null),u=Y(null),d=Y(!0),h=ur(()=>{var f;if(!e){s(null),i(!1);return}(f=u.current)==null||f.abort();const o=new AbortController;u.current=o,i(_=>n===null?!0:_),hn(e,o.signal).then(_=>{d.current&&!o.signal.aborted&&(s(_),l(null),i(!1))}).catch(_=>{d.current&&!o.signal.aborted&&(l(_.message??"Failed to fetch"),i(!1))})},[e,...t]);return oe(()=>(d.current=!0,h(),()=>{var o;d.current=!1,(o=u.current)==null||o.abort()}),[h]),oe(()=>{const o=le.value;if(o<=0||!e)return;const f=setInterval(h,o);return()=>clearInterval(f)},[h,e,le.value]),{data:n,loading:a,error:c,refetch:h}}function T(e){if(!e)return"—";const t=new Date(e);return t.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",second:"2-digit"})+" "+t.toLocaleDateString(void 0,{month:"short",day:"numeric"})}function pn(e){return e.toLocaleString()}function B(e,t=8){return e.length>t?e.slice(0,t):e}const fn=[{header:"Task",accessor:e=>r("span",{class:"font-medium",children:e.task_name})},{header:"State",accessor:e=>r(O,{status:e.state})},{header:"Failures",accessor:e=>r("span",{class:e.failure_count>0?"text-danger tabular-nums":"tabular-nums",children:e.failure_count})},{header:"Threshold",accessor:e=>r("span",{class:"tabular-nums",children:e.threshold})},{header:"Window",accessor:e=>`${(e.window_ms/1e3).toFixed(0)}s`},{header:"Cooldown",accessor:e=>`${(e.cooldown_ms/1e3).toFixed(0)}s`},{header:"Last Failure",accessor:e=>r("span",{class:"text-muted",children:T(e.last_failure_at)})}];function _n(e){const{data:t,loading:n}=$("/api/circuit-breakers");return n&&!t?r(A,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ht,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Circuit Breakers"}),r("p",{class:"text-xs text-muted",children:"Automatic failure protection status"})]})]}),t!=null&&t.length?r(L,{columns:fn,data:t}):r(N,{message:"No circuit breakers configured",subtitle:"Circuit breakers activate when tasks fail repeatedly"})]})}const mn={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]"};function W({onClick:e,variant:t="primary",disabled:n,children:s,class:a=""}){return r("button",{type:"button",onClick:e,disabled:n,class:`inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-[13px] font-medium cursor-pointer transition-all duration-150 disabled:opacity-40 disabled:cursor-default disabled:shadow-none border-none ${mn[t]} ${a}`,children:s})}function gn({message:e,onConfirm:t,onCancel:n}){return r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-fade-in",onClick:n,children:r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-2xl dark:shadow-black/50 p-6 max-w-sm mx-4 border dark:border-white/[0.08] border-slate-200 animate-slide-in",onClick:s=>s.stopPropagation(),children:[r("div",{class:"flex items-start gap-3 mb-5",children:[r("div",{class:"p-2 rounded-lg bg-danger-dim shrink-0",children:r(Hr,{class:"w-5 h-5 text-danger",strokeWidth:2})}),r("p",{class:"text-sm dark:text-gray-200 text-slate-700 pt-1",children:e})]}),r("div",{class:"flex justify-end gap-2.5",children:[r(W,{variant:"ghost",onClick:n,children:"Cancel"}),r(W,{variant:"danger",onClick:t,children:"Confirm"})]})]})})}function Kt({page:e,pageSize:t,itemCount:n,onPageChange:s}){return r("div",{class:"flex items-center justify-between px-4 py-3 text-[13px] text-muted border-t dark:border-white/[0.04] border-slate-100",children:[r("span",{children:["Showing ",e*t+1,"\\u2013",e*t+n," items"]}),r("div",{class:"flex gap-1.5",children:[r("button",{type:"button",onClick:()=>s(e-1),disabled:e===0,class:"inline-flex items-center gap-1 px-3 py-1.5 rounded-lg dark:bg-surface-3 bg-slate-100 dark:text-gray-300 text-slate-600 border dark:border-white/[0.06] border-slate-200 disabled:opacity-30 cursor-pointer disabled:cursor-default hover:enabled:dark:bg-surface-4 hover:enabled:bg-slate-200 transition-all duration-150 text-[13px]",children:[r(jr,{class:"w-3.5 h-3.5"}),"Prev"]}),r("button",{type:"button",onClick:()=>s(e+1),disabled:n{try{await K(`/api/dead-letters/${o}/retry`),j("Dead letter retried","success"),l()}catch{j("Failed to retry dead letter","error")}},d=async()=>{a(!1);try{const o=await K("/api/dead-letters/purge");j(`Purged ${o.purged} dead letters`,"success"),l()}catch{j("Failed to purge dead letters","error")}},h=[{header:"ID",accessor:o=>r("span",{class:"font-mono text-xs text-accent-light",children:B(o.id)})},{header:"Original Job",accessor:o=>r("a",{href:`/jobs/${o.original_job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:B(o.original_job_id)})},{header:"Task",accessor:o=>r("span",{class:"font-medium",children:o.task_name})},{header:"Queue",accessor:"queue"},{header:"Error",accessor:o=>r("span",{class:"text-danger text-xs",title:o.error??"",children:o.error?o.error.length>50?o.error.slice(0,50)+"…":o.error:"—"}),className:"max-w-[250px]"},{header:"Retries",accessor:o=>r("span",{class:"text-warning tabular-nums",children:o.retry_count})},{header:"Failed At",accessor:o=>r("span",{class:"text-muted",children:T(o.failed_at)})},{header:"Actions",accessor:o=>r(W,{onClick:()=>u(o.id),children:[r(Ft,{class:"w-3.5 h-3.5"}),"Retry"]})}];return c&&!i?r(A,{}):r("div",{children:[r("div",{class:"flex items-center justify-between mb-6",children:[r("div",{class:"flex items-center gap-3",children:[r("div",{class:"p-2 rounded-lg bg-danger-dim",children:r(Qe,{class:"w-5 h-5 text-danger",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Dead Letters"}),r("p",{class:"text-xs text-muted",children:"Failed jobs that exhausted all retries"})]})]}),i&&i.length>0&&r(W,{variant:"danger",onClick:()=>a(!0),children:[r(Fr,{class:"w-3.5 h-3.5"}),"Purge All"]})]}),i!=null&&i.length?r(L,{columns:h,data:i,children:r(Kt,{page:t,pageSize:Re,itemCount:i.length,onPageChange:n})}):r(N,{message:"No dead letters",subtitle:"All jobs are processing normally"}),s&&r(gn,{message:"Purge all dead letters? This cannot be undone.",onConfirm:d,onCancel:()=>a(!1)})]})}const xn={pending:"#ffa726",running:"#42a5f5",complete:"#66bb6a",failed:"#ef5350",dead:"#ef5350",cancelled:"#a0a0b0"};function bn({dag:e}){if(!e.nodes||e.nodes.length<=1)return null;const t=160,n=36,s=40,a=20,i={},c={};e.nodes.forEach(p=>{i[p.id]=[],c[p.id]=0}),e.edges.forEach(p=>{i[p.from]||(i[p.from]=[]),i[p.from].push(p.to),c[p.to]=(c[p.to]||0)+1});const l=[],u=new Set;let d=e.nodes.filter(p=>(c[p.id]||0)===0).map(p=>p.id);for(;d.length;){l.push([...d]);for(const m of d)u.add(m);const p=[];d.forEach(m=>{(i[m]||[]).forEach(g=>{!u.has(g)&&!p.includes(g)&&p.push(g)})}),d=p}e.nodes.forEach(p=>{u.has(p.id)||(l.push([p.id]),u.add(p.id))});const h={};for(const p of e.nodes)h[p.id]=p;const o={};let f=0,_=0;return l.forEach((p,m)=>{p.forEach((g,v)=>{const x=20+m*(t+s),S=20+v*(n+a);o[g]={x,y:S},f=Math.max(f,x+t+20),_=Math.max(_,S+n+20)})}),r("div",{class:"mt-4",children:[r("h3",{class:"text-sm text-muted mb-2",children:"Dependency Graph"}),r("div",{class:"dark:bg-surface-2 bg-white rounded-lg shadow-sm dark:shadow-black/30 p-4 overflow-x-auto border border-transparent dark:border-white/5",children:r("svg",{width:f,height:_,role:"img","aria-label":"Job dependency graph",children:[r("title",{children:"Job dependency graph"}),r("defs",{children:r("marker",{id:"arrow",viewBox:"0 0 10 10",refX:"10",refY:"5",markerWidth:"8",markerHeight:"8",orient:"auto",children:r("path",{d:"M0,0 L10,5 L0,10 z",fill:"#a0a0b0"})})}),e.edges.map((p,m)=>{const g=o[p.from],v=o[p.to];return!g||!v?null:r("line",{x1:g.x+t,y1:g.y+n/2,x2:v.x,y2:v.y+n/2,stroke:"#a0a0b0","stroke-width":"1.5",fill:"none","marker-end":"url(#arrow)"},m)}),e.nodes.map(p=>{const m=o[p.id];if(!m)return null;const g=xn[p.status]||"#a0a0b0";return r("g",{class:"cursor-pointer",onClick:()=>he(`/jobs/${p.id}`),children:[r("rect",{x:m.x,y:m.y,width:t,height:n,fill:`${g}22`,stroke:g,"stroke-width":"1.5",rx:"6",ry:"6"}),r("text",{x:m.x+8,y:m.y+14,fill:g,"font-size":"10","font-weight":"600",children:p.status.toUpperCase()}),r("text",{x:m.x+8,y:m.y+28,"font-size":"10",fill:"#a0a0b0",children:p.task_name.length>18?p.task_name.slice(-18):p.task_name})]},p.id)})]})})]})}function Ye({progress:e}){return e==null?r("span",{class:"text-muted text-xs",children:"—"}):r("span",{class:"inline-flex items-center gap-2",children:[r("span",{class:"inline-block w-16 h-1.5 rounded-full dark:bg-white/[0.08] bg-slate-200 overflow-hidden",children:r("span",{class:"block h-full rounded-full bg-gradient-to-r from-accent to-accent-light transition-[width] duration-300",style:{width:`${e}%`}})}),r("span",{class:"text-xs tabular-nums text-muted",children:[e,"%"]})]})}const yn=[{header:"Attempt",accessor:"attempt"},{header:"Error",accessor:"error",className:"max-w-xs truncate"},{header:"Failed At",accessor:e=>r("span",{class:"text-muted",children:T(e.failed_at)})}],kn=[{header:"Time",accessor:e=>r("span",{class:"text-muted",children:T(e.logged_at)})},{header:"Level",accessor:e=>r(O,{status:e.level==="error"?"failed":e.level==="warning"?"pending":"complete"})},{header:"Message",accessor:"message"},{header:"Extra",accessor:e=>e.extra??"—",className:"max-w-[200px] truncate"}],wn=[{header:"Replay Job",accessor:e=>r("a",{href:`/jobs/${e.replay_job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:B(e.replay_job_id)})},{header:"Replayed At",accessor:e=>r("span",{class:"text-muted",children:T(e.replayed_at)})},{header:"Original Error",accessor:e=>e.original_error??"—",className:"max-w-[200px] truncate"},{header:"Replay Error",accessor:e=>e.replay_error??"—",className:"max-w-[200px] truncate"}];function $n({id:e}){const{data:t,loading:n,refetch:s}=$(`/api/jobs/${e}`),{data:a}=$(`/api/jobs/${e}/errors`),{data:i}=$(`/api/jobs/${e}/logs`),{data:c}=$(`/api/jobs/${e}/replay-history`),{data:l}=$(`/api/jobs/${e}/dag`);if(n&&!t)return r(A,{});if(!t)return r(N,{message:`Job not found: ${e}`});const u=async()=>{try{const o=await K(`/api/jobs/${e}/cancel`);j(o.cancelled?"Job cancelled":"Failed to cancel job",o.cancelled?"success":"error"),s()}catch{j("Failed to cancel job","error")}},d=async()=>{try{const o=await K(`/api/jobs/${e}/replay`);j("Job replayed","success"),he(`/jobs/${o.replay_job_id}`)}catch{j("Failed to replay job","error")}},h={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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ar,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:["Job ",r("span",{class:"font-mono text-accent-light",children:B(t.id)})]}),r("p",{class:"text-xs text-muted",children:t.task_name})]})]}),r("div",{class:`dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-6 border dark:border-white/[0.06] border-slate-200 border-t-[3px] ${h[t.status]??"border-t-muted"}`,children:[r("div",{class:"grid grid-cols-[140px_1fr] gap-x-6 gap-y-3 text-[13px]",children:[r("span",{class:"text-muted font-medium",children:"ID"}),r("span",{class:"font-mono text-xs break-all dark:text-gray-300 text-slate-600",children:t.id}),r("span",{class:"text-muted font-medium",children:"Status"}),r("span",{children:r(O,{status:t.status})}),r("span",{class:"text-muted font-medium",children:"Task"}),r("span",{class:"font-medium",children:t.task_name}),r("span",{class:"text-muted font-medium",children:"Queue"}),r("span",{children:t.queue}),r("span",{class:"text-muted font-medium",children:"Priority"}),r("span",{children:t.priority}),r("span",{class:"text-muted font-medium",children:"Progress"}),r("span",{children:r(Ye,{progress:t.progress})}),r("span",{class:"text-muted font-medium",children:"Retries"}),r("span",{class:t.retry_count>0?"text-warning":"",children:[t.retry_count," / ",t.max_retries]}),r("span",{class:"text-muted font-medium",children:"Created"}),r("span",{class:"text-muted",children:T(t.created_at)}),r("span",{class:"text-muted font-medium",children:"Scheduled"}),r("span",{class:"text-muted",children:T(t.scheduled_at)}),r("span",{class:"text-muted font-medium",children:"Started"}),r("span",{class:"text-muted",children:t.started_at?T(t.started_at):"—"}),r("span",{class:"text-muted font-medium",children:"Completed"}),r("span",{class:"text-muted",children:t.completed_at?T(t.completed_at):"—"}),r("span",{class:"text-muted font-medium",children:"Timeout"}),r("span",{children:[(t.timeout_ms/1e3).toFixed(0),"s"]}),t.error&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Error"}),r("span",{class:"text-danger text-xs font-mono bg-danger-dim rounded px-2 py-1",children:t.error})]}),t.unique_key&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Unique Key"}),r("span",{class:"font-mono text-xs",children:t.unique_key})]}),t.metadata&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Metadata"}),r("span",{class:"font-mono text-xs",children:t.metadata})]})]}),r("div",{class:"flex gap-2.5 mt-5 pt-5 border-t dark:border-white/[0.06] border-slate-100",children:[t.status==="pending"&&r(W,{variant:"danger",onClick:u,children:"Cancel Job"}),r(W,{onClick:d,children:[r(Ft,{class:"w-3.5 h-3.5"}),"Replay"]})]})]}),a&&a.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Error History ",r("span",{class:"text-muted font-normal",children:["(",a.length,")"]})]}),r(L,{columns:yn,data:a})]}),i&&i.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Task Logs ",r("span",{class:"text-muted font-normal",children:["(",i.length,")"]})]}),r(L,{columns:kn,data:i})]}),c&&c.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Replay History ",r("span",{class:"text-muted font-normal",children:["(",c.length,")"]})]}),r(L,{columns:wn,data:c})]}),l&&r(bn,{dag:l}),r("div",{class:"mt-6",children:r("a",{href:"/jobs",class:"text-accent-light text-[13px] hover:underline",children:["←"," Back to jobs"]})})]})}const Cn={pending:{color:"text-warning",bg:"bg-warning-dim",border:"border-l-warning",icon:$e},running:{color:"text-info",bg:"bg-info-dim",border:"border-l-info",icon:Wt},completed:{color:"text-success",bg:"bg-success-dim",border:"border-l-success",icon:Nt},failed:{color:"text-danger",bg:"bg-danger-dim",border:"border-l-danger",icon:Et},dead:{color:"text-danger",bg:"bg-danger-dim",border:"border-l-danger",icon:Qe},cancelled:{color:"text-muted",bg:"bg-muted/10",border:"border-l-muted/40",icon:Pr}};function Sn({label:e,value:t,color:n}){const s=Cn[e],a=n??(s==null?void 0:s.color)??"text-accent-light",i=(s==null?void 0:s.bg)??"bg-accent-dim",c=(s==null?void 0:s.border)??"border-l-accent",l=(s==null?void 0:s.icon)??$e;return r("div",{class:`dark:bg-surface-2 bg-white rounded-xl p-5 shadow-sm dark:shadow-black/20 border-l-[3px] ${c} dark:border-t dark:border-r dark:border-b dark:border-white/[0.04] border border-slate-100 transition-all duration-150 hover:shadow-md hover:dark:shadow-black/30`,children:r("div",{class:"flex items-start justify-between",children:[r("div",{children:[r("div",{class:`text-3xl font-bold tabular-nums tracking-tight ${a}`,children:pn(t)}),r("div",{class:"text-xs text-muted uppercase mt-1.5 tracking-wider font-medium",children:e})]}),r("div",{class:`p-2 rounded-lg ${i}`,children:r(l,{class:`w-5 h-5 ${a}`,strokeWidth:1.8})})]})})}const Mn=["pending","running","completed","failed","dead","cancelled"];function Xt({stats:e}){return r("div",{class:"grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-8",children:Mn.map(t=>r(Sn,{label:t,value:e[t]??0},t))})}const Fe=20,Tn=[{header:"ID",accessor:e=>r("span",{class:"font-mono text-xs text-accent-light",children:B(e.id)})},{header:"Task",accessor:"task_name"},{header:"Queue",accessor:"queue"},{header:"Status",accessor:e=>r(O,{status:e.status})},{header:"Priority",accessor:"priority"},{header:"Progress",accessor:e=>r(Ye,{progress:e.progress})},{header:"Retries",accessor:e=>r("span",{class:e.retry_count>0?"text-warning":"text-muted",children:[e.retry_count,"/",e.max_retries]})},{header:"Created",accessor:e=>r("span",{class:"text-muted",children:T(e.created_at)})}];function Pn(e,t){const n=new URLSearchParams;return n.set("limit",String(Fe)),n.set("offset",String(t*Fe)),e.status&&n.set("status",e.status),e.queue&&n.set("queue",e.queue),e.task&&n.set("task",e.task),e.metadata&&n.set("metadata",e.metadata),e.error&&n.set("error",e.error),e.created_after&&n.set("created_after",String(new Date(e.created_after).getTime())),e.created_before&&n.set("created_before",String(new Date(e.created_before).getTime())),`/api/jobs?${n}`}function Ln(e){const[t,n]=R({status:"",queue:"",task:"",metadata:"",error:"",created_after:"",created_before:""}),[s,a]=R(0),{data:i}=$("/api/stats"),{data:c,loading:l}=$(Pn(t,s),[t.status,t.queue,t.task,t.metadata,t.error,t.created_after,t.created_before,s]),u=(h,o)=>{n(f=>({...f,[h]:o})),a(0)},d="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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(qt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Jobs"}),r("p",{class:"text-xs text-muted",children:"Browse and filter task queue jobs"})]})]}),i&&r(Xt,{stats:i}),r("div",{class:"dark:bg-surface-2 bg-white rounded-xl p-4 mb-4 border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"flex items-center gap-2 mb-3 text-xs text-muted font-medium uppercase tracking-wider",children:[r(Or,{class:"w-3.5 h-3.5"}),"Filters"]}),r("div",{class:"grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2.5",children:[r("select",{class:d,value:t.status,onChange:h=>u("status",h.target.value),children:[r("option",{value:"",children:"All statuses"}),r("option",{value:"pending",children:"Pending"}),r("option",{value:"running",children:"Running"}),r("option",{value:"complete",children:"Complete"}),r("option",{value:"failed",children:"Failed"}),r("option",{value:"dead",children:"Dead"}),r("option",{value:"cancelled",children:"Cancelled"})]}),r("input",{class:d,placeholder:"Queue\\u2026",value:t.queue,onInput:h=>u("queue",h.target.value)}),r("input",{class:d,placeholder:"Task\\u2026",value:t.task,onInput:h=>u("task",h.target.value)}),r("input",{class:d,placeholder:"Metadata\\u2026",value:t.metadata,onInput:h=>u("metadata",h.target.value)}),r("input",{class:d,placeholder:"Error text\\u2026",value:t.error,onInput:h=>u("error",h.target.value)}),r("input",{class:d,type:"date",title:"Created after",value:t.created_after,onInput:h=>u("created_after",h.target.value)}),r("input",{class:d,type:"date",title:"Created before",value:t.created_before,onInput:h=>u("created_before",h.target.value)})]})]}),l&&!c?r(A,{}):c!=null&&c.length?r(L,{columns:Tn,data:c,onRowClick:h=>he(`/jobs/${h.id}`),children:r(Kt,{page:s,pageSize:Fe,itemCount:c.length,onPageChange:a})}):r(N,{message:"No jobs found",subtitle:"Try adjusting your filters"})]})}const jn=[{header:"Time",accessor:e=>r("span",{class:"text-muted",children:T(e.logged_at)})},{header:"Level",accessor:e=>r(O,{status:e.level==="error"?"failed":e.level==="warning"?"pending":"complete"})},{header:"Task",accessor:e=>r("span",{class:"font-medium",children:e.task_name})},{header:"Job",accessor:e=>r("a",{href:`/jobs/${e.job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:B(e.job_id)})},{header:"Message",accessor:"message"},{header:"Extra",accessor:e=>e.extra??"—",className:"max-w-[200px] truncate"}];function An(e){const[t,n]=R(""),[s,a]=R(""),i=new URLSearchParams({limit:"100"});t&&i.set("task",t),s&&i.set("level",s);const{data:c,loading:l}=$(`/api/logs?${i}`,[t,s]),u="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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Dt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Logs"}),r("p",{class:"text-xs text-muted",children:"Structured task execution logs"})]})]}),r("div",{class:"flex gap-2.5 mb-5",children:[r("input",{class:u+" w-44",placeholder:"Filter by task\\u2026",value:t,onInput:d=>n(d.target.value)}),r("select",{class:u,value:s,onChange:d=>a(d.target.value),children:[r("option",{value:"",children:"All levels"}),r("option",{value:"error",children:"Error"}),r("option",{value:"warning",children:"Warning"}),r("option",{value:"info",children:"Info"}),r("option",{value:"debug",children:"Debug"})]})]}),l&&!c?r(A,{}):c!=null&&c.length?r(L,{columns:jn,data:c}):r(N,{message:"No logs yet",subtitle:"Logs appear when tasks execute"})]})}function Rn({data:e}){const t=Y(null);return oe(()=>{const n=t.current;if(!n)return;const s=n.getContext("2d");if(!s)return;const a=window.devicePixelRatio||1,i=n.getBoundingClientRect();n.width=i.width*a,n.height=i.height*a,s.scale(a,a);const c=i.width,l=i.height;if(s.clearRect(0,0,c,l),!e.length){s.fillStyle="rgba(139,149,165,0.4)",s.font="12px -apple-system, sans-serif",s.textAlign="center",s.fillText("No timeseries data",c/2,l/2);return}const u={top:12,right:12,bottom:32,left:48},d=c-u.left-u.right,h=l-u.top-u.bottom,o=Math.max(...e.map(m=>m.success+m.failure),1),f=Math.max(3,d/e.length-2),_=Math.max(1,(d-f*e.length)/e.length);for(let m=0;m<=4;m++){const g=u.top+h*(1-m/4);s.strokeStyle="rgba(255,255,255,0.04)",s.lineWidth=1,s.beginPath(),s.moveTo(u.left,g),s.lineTo(c-u.right,g),s.stroke(),s.fillStyle="rgba(139,149,165,0.5)",s.font="10px -apple-system, sans-serif",s.textAlign="right",s.fillText(Math.round(o*m/4).toString(),u.left-6,g+3)}e.forEach((m,g)=>{const v=u.left+g*(f+_),x=m.success/o*h,S=m.failure/o*h;s.fillStyle="rgba(34,197,94,0.65)",s.beginPath();const I=u.top+h-x-S;s.roundRect(v,I,f,x,[2,2,0,0]),s.fill(),S>0&&(s.fillStyle="rgba(239,68,68,0.65)",s.beginPath(),s.roundRect(v,u.top+h-S,f,S,[0,0,2,2]),s.fill())}),s.fillStyle="rgba(139,149,165,0.5)",s.font="10px -apple-system, sans-serif",s.textAlign="center";const p=Math.min(6,e.length);for(let m=0;mr("span",{class:"font-medium",children:e.task_name})},{header:"Total",accessor:e=>r("span",{class:"tabular-nums",children:e.count})},{header:"Success",accessor:e=>r("span",{class:"text-success tabular-nums",children:e.success_count})},{header:"Failures",accessor:e=>r("span",{class:e.failure_count>0?"text-danger tabular-nums":"text-muted tabular-nums",children:e.failure_count})},{header:"Avg",accessor:e=>r("span",{class:`tabular-nums ${ve(e.avg_ms,{good:100,warn:500})}`,children:[e.avg_ms,"ms"]})},{header:"P50",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.p50_ms,"ms"]})},{header:"P95",accessor:e=>r("span",{class:`tabular-nums ${ve(e.p95_ms,{good:200,warn:1e3})}`,children:[e.p95_ms,"ms"]})},{header:"P99",accessor:e=>r("span",{class:`tabular-nums ${ve(e.p99_ms,{good:500,warn:2e3})}`,children:[e.p99_ms,"ms"]})},{header:"Min",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.min_ms,"ms"]})},{header:"Max",accessor:e=>r("span",{class:`tabular-nums ${ve(e.max_ms,{good:1e3,warn:5e3})}`,children:[e.max_ms,"ms"]})}],En=[{label:"1h",seconds:3600},{label:"6h",seconds:21600},{label:"24h",seconds:86400}];function Un(e){const[t,n]=R(3600),{data:s,loading:a}=$(`/api/metrics?since=${t}`,[t]),{data:i}=$(`/api/metrics/timeseries?since=${t}&bucket=${t<=3600?60:t<=21600?300:900}`,[t]),c=s?Object.entries(s).map(([l,u])=>({task_name:l,...u})):[];return r("div",{children:[r("div",{class:"flex items-center justify-between mb-6",children:[r("div",{class:"flex items-center gap-3",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Rt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Metrics"}),r("p",{class:"text-xs text-muted",children:"Task performance and throughput"})]})]}),r("div",{class:"flex gap-1 dark:bg-surface-3 bg-slate-100 rounded-lg p-1",children:En.map(l=>r("button",{type:"button",onClick:()=>n(l.seconds),class:`px-3 py-1.5 text-xs font-medium rounded-md border-none cursor-pointer transition-all duration-150 ${t===l.seconds?"bg-accent text-white shadow-sm shadow-accent/20":"bg-transparent dark:text-gray-400 text-slate-500 hover:dark:text-white hover:text-slate-900"}`,children:l.label},l.label))})]}),i&&i.length>0&&r(Rn,{data:i}),a&&!s?r(A,{}):c.length?r(L,{columns:Nn,data:c}):r(N,{message:"No metrics yet",subtitle:"Run some tasks to see performance data"})]})}function In({data:e}){const t=Y(null);oe(()=>{const s=t.current;if(!s)return;const a=s.getContext("2d");if(!a)return;const i=window.devicePixelRatio||1,c=s.getBoundingClientRect();s.width=c.width*i,s.height=c.height*i,a.scale(i,i);const l=c.width,u=c.height;if(a.clearRect(0,0,l,u),e.length<2){a.fillStyle="rgba(139,149,165,0.4)",a.font="12px -apple-system, sans-serif",a.textAlign="center",a.fillText("Collecting data…",l/2,u/2);return}const d=Math.max(...e,1),h={top:12,right:12,bottom:24,left:44},o=l-h.left-h.right,f=u-h.top-h.bottom;for(let p=0;p<=4;p++){const m=h.top+f*(1-p/4);a.strokeStyle="rgba(255,255,255,0.04)",a.lineWidth=1,a.beginPath(),a.moveTo(h.left,m),a.lineTo(l-h.right,m),a.stroke(),a.fillStyle="rgba(139,149,165,0.5)",a.font="10px -apple-system, sans-serif",a.textAlign="right",a.fillText((d*p/4).toFixed(1),h.left-6,m+3)}const _=a.createLinearGradient(0,h.top,0,h.top+f);if(_.addColorStop(0,"rgba(34,197,94,0.2)"),_.addColorStop(1,"rgba(34,197,94,0.01)"),a.beginPath(),a.moveTo(h.left,h.top+f),e.forEach((p,m)=>{const g=h.left+m/(e.length-1)*o,v=h.top+f*(1-p/d);a.lineTo(g,v)}),a.lineTo(h.left+o,h.top+f),a.closePath(),a.fillStyle=_,a.fill(),a.beginPath(),e.forEach((p,m)=>{const g=h.left+m/(e.length-1)*o,v=h.top+f*(1-p/d);m===0?a.moveTo(g,v):a.lineTo(g,v)}),a.strokeStyle="#22c55e",a.lineWidth=2,a.lineJoin="round",a.stroke(),e.length>0){const p=h.left+o,m=h.top+f*(1-e[e.length-1]/d);a.beginPath(),a.arc(p,m,3,0,Math.PI*2),a.fillStyle="#22c55e",a.fill(),a.beginPath(),a.arc(p,m,5,0,Math.PI*2),a.strokeStyle="rgba(34,197,94,0.3)",a.lineWidth=2,a.stroke()}},[e]);const n=e.length>0?e[e.length-1]:0;return r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-5 mb-6 border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"flex items-center justify-between mb-4",children:[r("div",{class:"flex items-center gap-2",children:[r(Dr,{class:"w-4 h-4 text-success",strokeWidth:2}),r("h3",{class:"text-sm font-medium dark:text-gray-300 text-slate-600",children:"Throughput"})]}),r("span",{class:"text-xl font-bold tabular-nums text-success",children:[n.toFixed(1)," ",r("span",{class:"text-xs font-normal text-muted",children:"jobs/s"})]})]}),r("canvas",{ref:t,class:"w-full",style:{height:"180px"}})]})}const On=[{header:"ID",accessor:e=>r("span",{class:"font-mono text-xs text-accent-light",children:B(e.id)})},{header:"Task",accessor:"task_name"},{header:"Queue",accessor:"queue"},{header:"Status",accessor:e=>r(O,{status:e.status})},{header:"Progress",accessor:e=>r(Ye,{progress:e.progress})},{header:"Created",accessor:e=>r("span",{class:"text-muted",children:T(e.created_at)})}];function qn(e){const{data:t,loading:n}=$("/api/stats"),{data:s}=$("/api/jobs?limit=10"),a=Y(0),i=Y([]);if(t){const c=t.completed||0,l=le.value||5e3;let u=0;a.current>0&&(u=parseFloat(((c-a.current)/(l/1e3)).toFixed(1))),a.current=c,i.current=[...i.current.slice(-59),u]}return n&&!t?r(A,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(It,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Overview"}),r("p",{class:"text-xs text-muted",children:"Real-time queue status"})]})]}),t&&r(Xt,{stats:t}),r(In,{data:i.current}),r("div",{class:"flex items-center gap-2 mb-4 mt-8",children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700",children:"Recent Jobs"}),r("span",{class:"text-xs text-muted",children:"(latest 10)"})]}),s!=null&&s.length?r(L,{columns:On,data:s,onRowClick:c=>he(`/jobs/${c.id}`)}):null]})}function Wn(e){const{data:t,loading:n,refetch:s}=$("/api/stats/queues"),{data:a,refetch:i}=$("/api/queues/paused"),c=new Set(a??[]),l=t?Object.entries(t).map(([o,f])=>({name:o,pending:f.pending??0,running:f.running??0,paused:c.has(o)})):[],u=async o=>{try{await K(`/api/queues/${encodeURIComponent(o)}/pause`),j(`Queue "${o}" paused`,"success"),s(),i()}catch{j(`Failed to pause queue "${o}"`,"error")}},d=async o=>{try{await K(`/api/queues/${encodeURIComponent(o)}/resume`),j(`Queue "${o}" resumed`,"success"),s(),i()}catch{j(`Failed to resume queue "${o}"`,"error")}},h=[{header:"Queue",accessor:o=>r("span",{class:"font-medium",children:o.name})},{header:"Pending",accessor:o=>r("span",{class:"text-warning tabular-nums font-medium",children:o.pending})},{header:"Running",accessor:o=>r("span",{class:"text-info tabular-nums font-medium",children:o.running})},{header:"Status",accessor:o=>r(O,{status:o.paused?"paused":"active"})},{header:"Actions",accessor:o=>o.paused?r(W,{onClick:()=>d(o.name),children:[r(Wt,{class:"w-3.5 h-3.5"}),"Resume"]}):r(W,{variant:"ghost",onClick:()=>u(o.name),children:[r(Ur,{class:"w-3.5 h-3.5"}),"Pause"]})}];return n&&!t?r(A,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ot,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Queue Management"}),r("p",{class:"text-xs text-muted",children:"Monitor and control individual queues"})]})]}),l.length?r(L,{columns:h,data:l}):r(N,{message:"No queues found",subtitle:"Queues appear when tasks are enqueued"})]})}const Fn=[{header:"Name",accessor:e=>r("span",{class:"font-medium",children:e.name})},{header:"Scope",accessor:e=>r(O,{status:e.scope})},{header:"Health",accessor:e=>r(O,{status:e.health})},{header:"Init (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.init_duration_ms.toFixed(1),"ms"]})},{header:"Recreations",accessor:e=>r("span",{class:`tabular-nums ${e.recreations>0?"text-warning":"text-muted"}`,children:e.recreations})},{header:"Dependencies",accessor:e=>e.depends_on.length?r("span",{class:"text-xs",children:e.depends_on.join(", ")}):r("span",{class:"text-muted",children:"—"})},{header:"Pool",accessor:e=>e.pool?r("span",{class:"text-xs tabular-nums",children:[r("span",{class:"text-info",children:e.pool.active}),"/",e.pool.size," active,"," ",r("span",{class:"text-muted",children:[e.pool.idle," idle"]})]}):r("span",{class:"text-muted",children:"—"})}];function Dn(e){const{data:t,loading:n}=$("/api/resources");return n&&!t?r(A,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(At,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Resources"}),r("p",{class:"text-xs text-muted",children:"Worker dependency injection runtime"})]})]}),t!=null&&t.length?r(L,{columns:Fn,data:t}):r(N,{message:"No resources registered",subtitle:"Resources appear when workers start with DI configuration"})]})}const Hn=[{header:"Handler",accessor:e=>r("span",{class:"font-medium",children:e.handler})},{header:"Reconstructions",accessor:e=>r("span",{class:"tabular-nums",children:e.reconstructions})},{header:"Avg (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.avg_ms.toFixed(1),"ms"]})},{header:"Errors",accessor:e=>r("span",{class:`tabular-nums ${e.errors>0?"text-danger font-medium":"text-muted"}`,children:e.errors})}],zn=[{header:"Strategy",accessor:e=>r("span",{class:"font-medium uppercase text-xs tracking-wide",children:e.strategy})},{header:"Count",accessor:e=>r("span",{class:"tabular-nums",children:e.count})},{header:"Avg (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.avg_ms.toFixed(1),"ms"]})}];function Bn(e){const{data:t,loading:n}=$("/api/proxy-stats"),{data:s,loading:a}=$("/api/interception-stats"),i=t?Object.entries(t).map(([l,u])=>({handler:l,...u})):[],c=s?Object.entries(s).map(([l,u])=>({strategy:l,...u})):[];return r("div",{children:[r("div",{class:"flex items-center gap-3 mb-8",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ut,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"System Internals"}),r("p",{class:"text-xs text-muted",children:"Proxy reconstruction and interception metrics"})]})]}),r("div",{class:"mb-8",children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:"Proxy Reconstruction"}),n&&!t?r(A,{}):i.length?r(L,{columns:Hn,data:i}):r(N,{message:"No proxy stats available",subtitle:"Stats appear when proxy handlers are used"})]}),r("div",{children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:"Interception"}),a&&!s?r(A,{}):c.length?r(L,{columns:zn,data:c}):r(N,{message:"No interception stats available",subtitle:"Stats appear when argument interception is enabled"})]})]})}function Jn(e){const{data:t,loading:n}=$("/api/workers"),{data:s}=$("/api/stats");return n&&!t?r(A,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(We,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Workers"}),r("p",{class:"text-xs text-muted",children:[(t==null?void 0:t.length)??0," active ","·"," ",(s==null?void 0:s.running)??0," running jobs"]})]})]}),t!=null&&t.length?r("div",{class:"grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-4",children:t.map(a=>r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-5 border dark:border-white/[0.06] border-slate-200 transition-all duration-150 hover:shadow-md hover:dark:shadow-black/30",children:[r("div",{class:"flex items-center gap-2 mb-3",children:[r("span",{class:"w-2 h-2 rounded-full bg-success shadow-sm shadow-success/40"}),r("span",{class:"font-mono text-xs text-accent-light font-medium",children:a.worker_id})]}),r("div",{class:"space-y-2 text-[13px]",children:[r("div",{class:"flex items-center gap-2 text-muted",children:[r(We,{class:"w-3.5 h-3.5"}),"Queues: ",r("span",{class:"dark:text-gray-200 text-slate-700",children:a.queues})]}),r("div",{class:"flex items-center gap-2 text-muted",children:[r($e,{class:"w-3.5 h-3.5"}),"Last heartbeat:"," ",r("span",{class:"dark:text-gray-200 text-slate-700",children:T(a.last_heartbeat)})]}),r("div",{class:"flex items-center gap-2 text-muted",children:[r($e,{class:"w-3.5 h-3.5"}),"Registered:"," ",r("span",{class:"dark:text-gray-200 text-slate-700",children:T(a.registered_at)})]}),a.tags&&r("div",{class:"flex items-center gap-2 text-muted",children:[r(Wr,{class:"w-3.5 h-3.5"}),"Tags: ",r("span",{class:"dark:text-gray-200 text-slate-700",children:a.tags})]})]})]},a.worker_id))}):r(N,{message:"No active workers",subtitle:"Workers will appear when they connect"})]})}function Qn(){return r(sn,{children:[r(jt,{children:[r(qn,{path:"/"}),r(Ln,{path:"/jobs"}),r($n,{path:"/jobs/:id"}),r(Un,{path:"/metrics"}),r(An,{path:"/logs"}),r(Jn,{path:"/workers"}),r(_n,{path:"/circuit-breakers"}),r(vn,{path:"/dead-letters"}),r(Dn,{path:"/resources"}),r(Wn,{path:"/queues"}),r(Bn,{path:"/system"})]}),r(ln,{})]})}ir(r(Qn,{}),document.getElementById("app")); +
From 762628664bd7a99ce4c1a88ce3648252927a4e5c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:38:20 +0530 Subject: [PATCH 08/11] fix: resolve biome lint warnings in dashboard Replace non-null assertion with type cast in main.tsx. Use template literals instead of string concatenation in sidebar, logs, and dead-letters pages. Zero warnings in CI annotations. --- dashboard/src/components/layout/sidebar.tsx | 2 +- dashboard/src/main.tsx | 2 +- dashboard/src/pages/logs.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index 5c11c01..0064754 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -55,7 +55,7 @@ const NAV_GROUPS: NavGroup[] = [ function isActive(current: string, path: string): boolean { if (path === "/") return current === "/"; - return current === path || current.startsWith(path + "/"); + return current === path || current.startsWith(`${path}/`); } export function Sidebar() { diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 4aa1673..b2c3c36 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -2,4 +2,4 @@ import { render } from "preact"; import { App } from "./app"; import "./index.css"; -render(, document.getElementById("app")!); +render(, document.getElementById("app") as HTMLElement); diff --git a/dashboard/src/pages/logs.tsx b/dashboard/src/pages/logs.tsx index 51ef323..a93b1a7 100644 --- a/dashboard/src/pages/logs.tsx +++ b/dashboard/src/pages/logs.tsx @@ -62,7 +62,7 @@ export function Logs(_props: RoutableProps) {
setTaskFilter((e.target as HTMLInputElement).value)} From be9892c68efb238ffffb6e0eeeb7b1e8fe7ac39a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:38:29 +0530 Subject: [PATCH 09/11] feat: add job search bar and auto-refresh indicator to header Search input in header navigates directly to job detail page by ID. Auto-refresh indicator shows relative time since last data fetch ("just now", "3s ago") and highlights the refresh icon when active. --- dashboard/src/components/layout/header.tsx | 56 ++++++++++++++++++++-- dashboard/src/hooks/use-api.ts | 3 +- dashboard/src/hooks/use-auto-refresh.ts | 5 ++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/layout/header.tsx b/dashboard/src/components/layout/header.tsx index bbbd569..62b5d8c 100644 --- a/dashboard/src/components/layout/header.tsx +++ b/dashboard/src/components/layout/header.tsx @@ -1,8 +1,37 @@ -import { Moon, RefreshCw, Sun, Zap } from "lucide-preact"; -import { refreshInterval, setRefreshInterval } from "../../hooks/use-auto-refresh"; +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 (
@@ -15,10 +44,28 @@ export function Header() { + {/* 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 */} + {/* Refresh interval + indicator */}
- + 0 ? "text-accent" : "text-muted"}`} + strokeWidth={refreshInterval.value > 0 ? 2 : 1.5} + /> +
diff --git a/dashboard/src/hooks/use-api.ts b/dashboard/src/hooks/use-api.ts index a6e3128..bae158e 100644 --- a/dashboard/src/hooks/use-api.ts +++ b/dashboard/src/hooks/use-api.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { api } from "../api/client"; -import { refreshInterval } from "./use-auto-refresh"; +import { markRefreshed, refreshInterval } from "./use-auto-refresh"; interface UseApiResult { data: T | null; @@ -35,6 +35,7 @@ export function useApi(url: string | null, deps: unknown[] = []): UseApiResul setData(result); setError(null); setLoading(false); + markRefreshed(); } }) .catch((err) => { diff --git a/dashboard/src/hooks/use-auto-refresh.ts b/dashboard/src/hooks/use-auto-refresh.ts index 536730d..96739f2 100644 --- a/dashboard/src/hooks/use-auto-refresh.ts +++ b/dashboard/src/hooks/use-auto-refresh.ts @@ -1,7 +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(); +} From 56e5ad759f30672ab0073aec7f2d883f2c5b9a8c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:38:38 +0530 Subject: [PATCH 10/11] feat: add bulk select and actions to jobs page DataTable supports optional checkbox selection with select-all header toggle. Jobs page shows bulk action bar when items are selected: Cancel Selected (with confirm dialog), Replay Selected, and Clear Selection. Actions process jobs sequentially and show toast with success/failure counts. --- dashboard/src/components/ui/data-table.tsx | 101 ++++++++++++++++----- dashboard/src/pages/jobs.tsx | 95 ++++++++++++++++++- 2 files changed, 169 insertions(+), 27 deletions(-) diff --git a/dashboard/src/components/ui/data-table.tsx b/dashboard/src/components/ui/data-table.tsx index 613546f..fba5878 100644 --- a/dashboard/src/components/ui/data-table.tsx +++ b/dashboard/src/components/ui/data-table.tsx @@ -11,19 +11,59 @@ interface DataTableProps { 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 }: DataTableProps) { +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) => ( @@ -31,27 +71,42 @@ export function DataTable({ columns, data, onRowClick, children }: DataTableP - {data.map((row, ri) => ( - 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" : "" - } ${ - onRowClick - ? "cursor-pointer hover:dark:bg-accent/[0.04] hover:bg-accent/[0.02]" - : "" - }`} - > - {columns.map((col, ci) => ( - - ))} - - ))} + {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}
- {typeof col.accessor === "function" - ? col.accessor(row) - : (row[col.accessor] as ComponentChildren)} -
+ toggleRow(key)} + onClick={(e) => e.stopPropagation()} + class="accent-accent cursor-pointer" + /> + + {typeof col.accessor === "function" + ? col.accessor(row) + : (row[col.accessor] as ComponentChildren)} +
diff --git a/dashboard/src/pages/jobs.tsx b/dashboard/src/pages/jobs.tsx index 86acbd8..e9c6f76 100644 --- a/dashboard/src/pages/jobs.tsx +++ b/dashboard/src/pages/jobs.tsx @@ -1,8 +1,11 @@ -import { ListTodo, Search } from "lucide-preact"; +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"; @@ -10,6 +13,7 @@ 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"; @@ -43,7 +47,10 @@ const JOB_COLUMNS: Column[] = [ ), }, - { header: "Created", accessor: (j) => {fmtTime(j.created_at)} }, + { + header: "Created", + accessor: (j) => {fmtTime(j.created_at)}, + }, ]; function buildUrl(filters: Filters, page: number): string { @@ -73,9 +80,15 @@ export function Jobs(_props: RoutableProps) { 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 } = useApi(buildUrl(filters, page), [ + const { + data: jobs, + loading, + refetch, + } = useApi(buildUrl(filters, page), [ filters.status, filters.queue, filters.task, @@ -89,6 +102,41 @@ export function Jobs(_props: RoutableProps) { 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 = @@ -168,12 +216,43 @@ export function Jobs(_props: RoutableProps) {
+ {/* Bulk action bar */} + {selected.size > 0 && ( +
+ + {selected.size} job{selected.size > 1 ? "s" : ""} selected + +
+ + + +
+
+ )} + {loading && !jobs ? ( ) : !jobs?.length ? ( ) : ( - route(`/jobs/${j.id}`)}> + 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)} + /> + )}
); } From 3d707acae77e46f30a38baa4b4e853523185702e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:38:47 +0530 Subject: [PATCH 11/11] feat: add error grouping toggle to dead letters page Toggle between list view (flat, paginated) and grouped view that collapses dead letters by error message. Groups show occurrence count, expandable item list, and Retry All button. Groups sorted by count descending so the most common errors appear first. --- dashboard/src/pages/dead-letters.tsx | 146 ++++++++++++++++++++++-- py_src/taskito/templates/dashboard.html | 99 +++++++++------- 2 files changed, 192 insertions(+), 53 deletions(-) diff --git a/dashboard/src/pages/dead-letters.tsx b/dashboard/src/pages/dead-letters.tsx index eeb0897..1da06ea 100644 --- a/dashboard/src/pages/dead-letters.tsx +++ b/dashboard/src/pages/dead-letters.tsx @@ -1,4 +1,4 @@ -import { RotateCcw, Skull, Trash2 } from "lucide-preact"; +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"; @@ -15,17 +15,38 @@ 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=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`, [ - page, - ]); + } = useApi( + `/api/dead-letters?limit=${grouped ? 200 : PAGE_SIZE}&offset=${grouped ? 0 : page * PAGE_SIZE}`, + [page, grouped], + ); const handleRetry = async (id: string) => { try { @@ -37,6 +58,23 @@ export function DeadLetters(_props: RoutableProps) { } }; + 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 { @@ -48,6 +86,13 @@ export function DeadLetters(_props: RoutableProps) { } }; + 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", @@ -70,7 +115,7 @@ export function DeadLetters(_props: RoutableProps) { header: "Error", accessor: (d) => ( - {d.error ? (d.error.length > 50 ? d.error.slice(0, 50) + "\u2026" : d.error) : "\u2014"} + {d.error ? (d.error.length > 50 ? `${d.error.slice(0, 50)}\u2026` : d.error) : "\u2014"} ), className: "max-w-[250px]", @@ -96,6 +141,8 @@ export function DeadLetters(_props: RoutableProps) { if (loading && !items) return ; + const groups = grouped && items ? groupByError(items) : []; + return (
@@ -108,17 +155,94 @@ export function DeadLetters(_props: RoutableProps) {

Failed jobs that exhausted all retries

- {items && items.length > 0 && ( - - )} +
+ {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 */ taskito dashboard - - + */const Vr=v("trending-up",[["path",{d:"M16 7h6v6",key:"box55l"}],["path",{d:"m22 7-8.5 8.5-5-5L2 17",key:"1t1m79"}]]);/** + * @license lucide-preact v0.577.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qr=v("triangle-alert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]]);/** + * @license lucide-preact v0.577.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Vt=v("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]);/** + * @license lucide-preact v0.577.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yr=v("zap",[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]]);var Zr=Symbol.for("preact-signals");function Ye(){if(B>1)B--;else{var e,t=!1;for((function(){var a=Me;for(Me=void 0;a!==void 0;)a.S.v===a.v&&(a.S.i=a.i),a=a.o})();ie!==void 0;){var n=ie;for(ie=void 0,Ce++;n!==void 0;){var s=n.u;if(n.u=void 0,n.f&=-3,!(8&n.f)&&Zt(n))try{n.c()}catch(a){t||(e=a,t=!0)}n=s}}if(Ce=0,B--,t)throw e}}var k=void 0;function Qt(e){var t=k;k=void 0;try{return e()}finally{k=t}}var ie=void 0,B=0,Ce=0,mt=0,Me=void 0,Te=0;function Yt(e){if(k!==void 0){var t=e.n;if(t===void 0||t.t!==k)return t={i:0,S:e,p:k.s,n:void 0,t:k,e:void 0,x:void 0,r:t},k.s!==void 0&&(k.s.n=t),k.s=t,e.n=t,32&k.f&&e.S(t),t;if(t.i===-1)return t.i=0,t.n!==void 0&&(t.n.p=t.p,t.p!==void 0&&(t.p.n=t.n),t.p=k.s,t.n=void 0,k.s.n=t,k.s=t),t}}function T(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.l=0,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}T.prototype.brand=Zr;T.prototype.h=function(){return!0};T.prototype.S=function(e){var t=this,n=this.t;n!==e&&e.e===void 0&&(e.x=n,this.t=e,n!==void 0?n.e=e:Qt(function(){var s;(s=t.W)==null||s.call(t)}))};T.prototype.U=function(e){var t=this;if(this.t!==void 0){var n=e.e,s=e.x;n!==void 0&&(n.x=s,e.e=void 0),s!==void 0&&(s.e=n,e.x=void 0),e===this.t&&(this.t=s,s===void 0&&Qt(function(){var a;(a=t.Z)==null||a.call(t)}))}};T.prototype.subscribe=function(e){var t=this;return Re(function(){var n=t.value,s=k;k=void 0;try{e(n)}finally{k=s}},{name:"sub"})};T.prototype.valueOf=function(){return this.value};T.prototype.toString=function(){return this.value+""};T.prototype.toJSON=function(){return this.value};T.prototype.peek=function(){var e=k;k=void 0;try{return this.value}finally{k=e}};Object.defineProperty(T.prototype,"value",{get:function(){var e=Yt(this);return e!==void 0&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(Ce>100)throw new Error("Cycle detected");(function(n){B!==0&&Ce===0&&n.l!==mt&&(n.l=mt,Me={S:n,v:n.v,i:n.i,o:Me})})(this),this.v=e,this.i++,Te++,B++;try{for(var t=this.t;t!==void 0;t=t.x)t.t.N()}finally{Ye()}}}});function re(e,t){return new T(e,t)}function Zt(e){for(var t=e.s;t!==void 0;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function Xt(e){for(var t=e.s;t!==void 0;t=t.n){var n=t.S.n;if(n!==void 0&&(t.r=n),t.S.n=t,t.i=-1,t.n===void 0){e.s=t;break}}}function Kt(e){for(var t=e.s,n=void 0;t!==void 0;){var s=t.p;t.i===-1?(t.S.U(t),s!==void 0&&(s.n=t.n),t.n!==void 0&&(t.n.p=s)):n=t,t.S.n=t.r,t.r!==void 0&&(t.r=void 0),t=s}e.s=n}function V(e,t){T.call(this,void 0),this.x=e,this.s=void 0,this.g=Te-1,this.f=4,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}V.prototype=new T;V.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if((36&this.f)==32||(this.f&=-5,this.g===Te))return!0;if(this.g=Te,this.f|=1,this.i>0&&!Zt(this))return this.f&=-2,!0;var e=k;try{Xt(this),k=this;var t=this.x();(16&this.f||this.v!==t||this.i===0)&&(this.v=t,this.f&=-17,this.i++)}catch(n){this.v=n,this.f|=16,this.i++}return k=e,Kt(this),this.f&=-2,!0};V.prototype.S=function(e){if(this.t===void 0){this.f|=36;for(var t=this.s;t!==void 0;t=t.n)t.S.S(t)}T.prototype.S.call(this,e)};V.prototype.U=function(e){if(this.t!==void 0&&(T.prototype.U.call(this,e),this.t===void 0)){this.f&=-33;for(var t=this.s;t!==void 0;t=t.n)t.S.U(t)}};V.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;e!==void 0;e=e.x)e.t.N()}};Object.defineProperty(V.prototype,"value",{get:function(){if(1&this.f)throw new Error("Cycle detected");var e=Yt(this);if(this.h(),e!==void 0&&(e.i=this.i),16&this.f)throw this.v;return this.v}});function Xr(e,t){return new V(e,t)}function er(e){var t=e.m;if(e.m=void 0,typeof t=="function"){B++;var n=k;k=void 0;try{t()}catch(s){throw e.f&=-2,e.f|=8,Ze(e),s}finally{k=n,Ye()}}}function Ze(e){for(var t=e.s;t!==void 0;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,er(e)}function Kr(e){if(k!==this)throw new Error("Out-of-order effect");Kt(this),k=e,this.f&=-2,8&this.f&&Ze(this),Ye()}function ne(e,t){this.x=e,this.m=void 0,this.s=void 0,this.u=void 0,this.f=32,this.name=t==null?void 0:t.name}ne.prototype.c=function(){var e=this.S();try{if(8&this.f||this.x===void 0)return;var t=this.x();typeof t=="function"&&(this.m=t)}finally{e()}};ne.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1,this.f&=-9,er(this),Xt(this),B++;var e=k;return k=this,Kr.bind(this,e)};ne.prototype.N=function(){2&this.f||(this.f|=2,this.u=ie,ie=this)};ne.prototype.d=function(){this.f|=8,1&this.f||Ze(this)};ne.prototype.dispose=function(){this.d()};function Re(e,t){var n=new ne(e,t);try{n.c()}catch(a){throw n.d(),a}var s=n.d.bind(n);return s[Symbol.dispose]=s,s}var ge;function se(e,t){w[e]=t.bind(null,w[e]||function(){})}function Pe(e){if(ge){var t=ge;ge=void 0,t()}ge=e&&e.S()}function tr(e){var t=this,n=e.data,s=tn(n);s.value=n;var a=je(function(){for(var o=t.__v;o=o.__;)if(o.__c){o.__c.__$f|=4;break}return t.__$u.c=function(){var i,l=t.__$u.S(),u=a.value;l(),gt(u)||((i=t.base)==null?void 0:i.nodeType)!==3?(t.__$f|=1,t.setState({})):t.base.data=u},Xr(function(){var i=s.value.value;return i===0?0:i===!0?"":i||""})},[]);return a.value}tr.displayName="_st";Object.defineProperties(T.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:tr},props:{configurable:!0,get:function(){return{data:this}}},__b:{configurable:!0,value:1}});se("__b",function(e,t){if(typeof t.type=="string"){var n,s=t.props;for(var a in s)if(a!=="children"){var o=s[a];o instanceof T&&(n||(t.__np=n={}),n[a]=o,s[a]=o.peek())}}e(t)});se("__r",function(e,t){e(t),Pe();var n,s=t.__c;s&&(s.__$f&=-2,(n=s.__$u)===void 0&&(s.__$u=n=(function(a){var o;return Re(function(){o=this}),o.c=function(){s.__$f|=1,s.setState({})},o})())),Pe(n)});se("__e",function(e,t,n,s){Pe(),e(t,n,s)});se("diffed",function(e,t){Pe();var n;if(typeof t.type=="string"&&(n=t.__e)){var s=t.__np,a=t.props;if(s){var o=n.U;if(o)for(var i in o){var l=o[i];l!==void 0&&!(i in s)&&(l.d(),o[i]=void 0)}else n.U=o={};for(var u in s){var d=o[u],h=s[u];d===void 0?(d=en(n,u,h,a),o[u]=d):d.o(h,a)}}}e(t)});function en(e,t,n,s){var a=t in e&&e.ownerSVGElement===void 0,o=re(n);return{o:function(i,l){o.value=i,s=l},d:Re(function(){var i=o.value.value;s[t]!==i&&(s[t]=i,a?e[t]=i:i?e.setAttribute(t,i):e.removeAttribute(t))})}}se("unmount",function(e,t){if(typeof t.type=="string"){var n=t.__e;if(n){var s=n.U;if(s){n.U=void 0;for(var a in s){var o=s[a];o&&o.d()}}}}else{var i=t.__c;if(i){var l=i.__$u;l&&(i.__$u=void 0,l.d())}}e(t)});se("__h",function(e,t,n,s){(s<3||s===9)&&(t.__$f|=2),e(t,n,s)});Y.prototype.shouldComponentUpdate=function(e,t){if(this.__R)return!0;var n=this.__$u,s=n&&n.s!==void 0;for(var a in t)return!0;if(this.__f||typeof this.u=="boolean"&&this.u===!0){if(!(s||2&this.__$f||4&this.__$f)||1&this.__$f)return!0}else if(!(s||4&this.__$f)||3&this.__$f)return!0;for(var o in e)if(o!=="__source"&&e[o]!==this.props[o])return!0;for(var i in this.props)if(!(i in e))return!0;return!1};function tn(e){return je(function(){return re(e)},[])}const J=re(5e3),De=re(Date.now());function rn(e){J.value=e}function nn(){De.value=Date.now()}const sn=localStorage.getItem("taskito-theme"),ee=re(sn??"dark");Re(()=>{const e=document.documentElement;ee.value==="dark"?(e.classList.add("dark"),e.classList.remove("light")):(e.classList.remove("dark"),e.classList.add("light")),localStorage.setItem("taskito-theme",ee.value)});function an(){ee.value=ee.value==="dark"?"light":"dark"}function on(){const[e,t]=M("");return X(()=>{const n=()=>{const a=Math.round((Date.now()-De.value)/1e3);t(a<2?"just now":`${a}s ago`)};n();const s=setInterval(n,1e3);return()=>clearInterval(s)},[De.value]),r("span",{class:"text-[11px] text-muted tabular-nums",children:e})}function cn(){const[e,t]=M("");return r("header",{class:"h-14 flex items-center justify-between px-6 dark:bg-surface-2/80 bg-white/80 backdrop-blur-md border-b dark:border-white/[0.06] border-slate-200 sticky top-0 z-40",children:[r("a",{href:"/",class:"flex items-center gap-2 no-underline group",children:[r("div",{class:"w-7 h-7 rounded-lg bg-gradient-to-br from-accent to-accent-light flex items-center justify-center shadow-md shadow-accent/20",children:r(Yr,{class:"w-4 h-4 text-white",strokeWidth:2.5})}),r("span",{class:"text-[15px] font-semibold dark:text-white text-slate-900 tracking-tight",children:"taskito"}),r("span",{class:"text-xs text-muted font-normal hidden sm:inline",children:"dashboard"})]}),r("form",{onSubmit:s=>{s.preventDefault();const a=e.trim();a&&(te(`/jobs/${a}`),t(""))},class:"flex items-center gap-2 dark:bg-surface-3 bg-slate-100 rounded-lg px-3 py-1.5 border dark:border-white/[0.06] border-slate-200 w-64",children:[r(Jt,{class:"w-3.5 h-3.5 text-muted shrink-0"}),r("input",{type:"text",placeholder:"Jump to job ID\\u2026",value:e,onInput:s=>t(s.target.value),class:"bg-transparent border-none outline-none text-[13px] dark:text-gray-200 text-slate-700 placeholder:text-muted/50 w-full"})]}),r("div",{class:"flex items-center gap-3",children:[r("div",{class:"flex items-center gap-2 text-xs",children:[r(Hr,{class:`w-3.5 h-3.5 ${J.value>0?"text-accent":"text-muted"}`,strokeWidth:J.value>0?2:1.5}),r("select",{class:"dark:bg-surface-3 bg-slate-100 dark:text-gray-300 text-slate-600 border dark:border-white/[0.06] border-slate-200 rounded-md px-2 py-1 text-xs cursor-pointer hover:dark:border-white/10 hover:border-slate-300 transition-colors",value:J.value,onChange:s=>rn(Number(s.target.value)),children:[r("option",{value:2e3,children:"2s"}),r("option",{value:5e3,children:"5s"}),r("option",{value:1e4,children:"10s"}),r("option",{value:0,children:"Off"})]}),r(on,{})]}),r("div",{class:"w-px h-5 dark:bg-white/[0.06] bg-slate-200"}),r("button",{type:"button",onClick:an,class:"p-2 rounded-lg dark:text-gray-400 text-slate-500 hover:dark:text-white hover:text-slate-900 hover:dark:bg-surface-3 hover:bg-slate-100 transition-all duration-150 border-none cursor-pointer bg-transparent",title:`Switch to ${ee.value==="dark"?"light":"dark"} mode`,children:ee.value==="dark"?r(Br,{class:"w-4 h-4"}):r(Dr,{class:"w-4 h-4"})})]})]})}const ln=[{title:"Monitoring",items:[{path:"/",label:"Overview",icon:Ft},{path:"/jobs",label:"Jobs",icon:zt},{path:"/metrics",label:"Metrics",icon:Ut},{path:"/logs",label:"Logs",icon:Bt}]},{title:"Infrastructure",items:[{path:"/workers",label:"Workers",icon:Fe},{path:"/queues",label:"Queues",icon:Dt},{path:"/resources",label:"Resources",icon:Et},{path:"/circuit-breakers",label:"Circuit Breakers",icon:Gt}]},{title:"Advanced",items:[{path:"/dead-letters",label:"Dead Letters",icon:Qe},{path:"/system",label:"System",icon:Wt}]}];function dn(e,t){return t==="/"?e==="/":e===t||e.startsWith(`${t}/`)}function un(){const[e,t]=M(ue());return X(()=>{const n=()=>t(ue());return addEventListener("popstate",n),addEventListener("pushstate",n),()=>{removeEventListener("popstate",n),removeEventListener("pushstate",n)}},[]),r("aside",{class:"w-60 shrink-0 border-r dark:border-white/[0.06] border-slate-200 dark:bg-surface-2/50 bg-slate-50/50 overflow-y-auto h-[calc(100vh-56px)]",children:r("nav",{class:"p-3 space-y-5 pt-4",children:ln.map(n=>r("div",{children:[r("div",{class:"px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.1em] text-muted/60",children:n.title}),r("div",{class:"space-y-0.5",children:n.items.map(s=>{const a=dn(e,s.path),o=s.icon;return r("a",{href:s.path,class:`flex items-center gap-2.5 px-3 py-2 text-[13px] rounded-lg transition-all duration-150 no-underline relative ${a?"dark:bg-accent/10 bg-accent/5 dark:text-white text-slate-900 font-medium":"dark:text-gray-400 text-slate-500 hover:dark:text-gray-200 hover:text-slate-700 hover:dark:bg-white/[0.03] hover:bg-slate-100"}`,children:[a&&r("div",{class:"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 rounded-r-full bg-accent"}),r(o,{class:`w-4 h-4 shrink-0 ${a?"text-accent":""}`,strokeWidth:a?2.2:1.8}),s.label]},s.path)})})]},n.title))})})}function hn({children:e}){return r("div",{class:"min-h-screen",children:[r(cn,{}),r("div",{class:"flex",children:[r(un,{}),r("main",{class:"flex-1 p-8 overflow-auto h-[calc(100vh-56px)]",children:r("div",{class:"max-w-[1280px] mx-auto",children:e})})]})]})}const z=re([]);let pn=0;function P(e,t="info",n=3e3){const s=String(++pn);z.value=[...z.value,{id:s,message:e,type:t}],setTimeout(()=>{z.value=z.value.filter(a=>a.id!==s)},n)}function fn(e){z.value=z.value.filter(t=>t.id!==e)}const mn={success:{border:"border-l-success",icon:Ot,iconColor:"text-success"},error:{border:"border-l-danger",icon:qt,iconColor:"text-danger"},info:{border:"border-l-info",icon:Wr,iconColor:"text-info"}};function _n(){const e=z.value;return e.length?r("div",{class:"fixed bottom-5 right-5 z-50 flex flex-col gap-2.5 max-w-sm",children:e.map(t=>{const n=mn[t.type],s=n.icon;return r("div",{class:`flex items-start gap-3 border-l-[3px] ${n.border} rounded-lg px-4 py-3.5 text-[13px] dark:bg-surface-2 bg-white shadow-xl dark:shadow-black/40 dark:text-gray-200 text-slate-700 animate-slide-in border dark:border-white/[0.06] border-slate-200`,role:"alert",children:[r(s,{class:`w-4.5 h-4.5 ${n.iconColor} shrink-0 mt-0.5`,strokeWidth:2}),r("span",{class:"flex-1",children:t.message}),r("button",{type:"button",onClick:()=>fn(t.id),class:"text-muted hover:dark:text-white hover:text-slate-900 transition-colors border-none bg-transparent cursor-pointer p-0.5",children:r(Vt,{class:"w-3.5 h-3.5"})})]},t.id)})}):null}const gn={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"},xn={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"};function W({status:e}){const t=gn[e]??"bg-muted/10 text-muted border-muted/20",n=xn[e]??"bg-muted";return r("span",{class:`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${t}`,children:[r("span",{class:`w-1.5 h-1.5 rounded-full ${n}`}),e]})}function R({columns:e,data:t,onRowClick:n,children:s,selectable:a,selectedKeys:o,rowKey:i,onSelectionChange:l}){const u=a&&i?t.map(i):[],d=a&&u.length>0&&u.every(f=>o==null?void 0:o.has(f)),h=()=>{l&&l(d?new Set:new Set(u))},c=f=>{if(!l||!o)return;const p=new Set(o);p.has(f)?p.delete(f):p.add(f),l(p)};return r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 overflow-hidden border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full border-collapse text-[13px]",children:[r("thead",{children:r("tr",{children:[a&&r("th",{class:"w-10 text-center px-3 py-2.5 dark:bg-surface-3/50 bg-slate-50 border-b dark:border-white/[0.04] border-slate-100",children:r("input",{type:"checkbox",checked:d,onChange:h,class:"accent-accent cursor-pointer"})}),e.map((f,p)=>r("th",{class:`text-left px-4 py-2.5 dark:bg-surface-3/50 bg-slate-50 text-muted font-semibold text-[11px] uppercase tracking-[0.05em] whitespace-nowrap border-b dark:border-white/[0.04] border-slate-100 ${f.className??""}`,children:f.header},p))]})}),r("tbody",{children:t.map((f,p)=>{const m=a&&i?i(f):String(p),b=o==null?void 0:o.has(m);return r("tr",{onClick:n?()=>n(f):void 0,class:`border-b dark:border-white/[0.03] border-slate-50 last:border-0 transition-colors duration-100 ${p%2===1?"dark:bg-white/[0.01] bg-slate-50/30":""} ${b?"dark:bg-accent/[0.08] bg-accent/[0.04]":""} ${n?"cursor-pointer hover:dark:bg-accent/[0.04] hover:bg-accent/[0.02]":""}`,children:[a&&r("td",{class:"w-10 text-center px-3 py-3",children:r("input",{type:"checkbox",checked:b,onChange:()=>c(m),onClick:x=>x.stopPropagation(),class:"accent-accent cursor-pointer"})}),e.map((x,g)=>r("td",{class:`px-4 py-3 whitespace-nowrap ${x.className??""}`,children:typeof x.accessor=="function"?x.accessor(f):f[x.accessor]},g))]},m)})})]})}),s]})}function U({message:e,subtitle:t}){return r("div",{class:"flex flex-col items-center justify-center py-16 text-center",children:[r("div",{class:"w-12 h-12 rounded-xl dark:bg-surface-3 bg-slate-100 flex items-center justify-center mb-4",children:r(qr,{class:"w-6 h-6 text-muted",strokeWidth:1.5})}),r("p",{class:"text-sm font-medium dark:text-gray-300 text-slate-600",children:e}),t&&r("p",{class:"text-xs text-muted mt-1",children:t})]})}function N(){return r("div",{class:"flex items-center justify-center py-20",children:r("div",{class:"flex items-center gap-3 text-muted text-sm",children:[r("div",{class:"w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin"}),r("span",{children:"Loading\\u2026"})]})})}class rr extends Error{constructor(t,n){super(n),this.status=t}}async function bn(e,t){const n=await fetch(e,{signal:t});if(!n.ok)throw new rr(n.status,`${n.status} ${n.statusText}`);return n.json()}async function q(e,t){const n=await fetch(e,{method:"POST",signal:t});if(!n.ok)throw new rr(n.status,`${n.status} ${n.statusText}`);return n.json()}function S(e,t=[]){const[n,s]=M(null),[a,o]=M(!0),[i,l]=M(null),u=K(null),d=K(!0),h=gr(()=>{var f;if(!e){s(null),o(!1);return}(f=u.current)==null||f.abort();const c=new AbortController;u.current=c,o(p=>n===null?!0:p),bn(e,c.signal).then(p=>{d.current&&!c.signal.aborted&&(s(p),l(null),o(!1),nn())}).catch(p=>{d.current&&!c.signal.aborted&&(l(p.message??"Failed to fetch"),o(!1))})},[e,...t]);return X(()=>(d.current=!0,h(),()=>{var c;d.current=!1,(c=u.current)==null||c.abort()}),[h]),X(()=>{const c=J.value;if(c<=0||!e)return;const f=setInterval(h,c);return()=>clearInterval(f)},[h,e,J.value]),{data:n,loading:a,error:i,refetch:h}}function j(e){if(!e)return"—";const t=new Date(e);return t.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",second:"2-digit"})+" "+t.toLocaleDateString(void 0,{month:"short",day:"numeric"})}function vn(e){return e.toLocaleString()}function G(e,t=8){return e.length>t?e.slice(0,t):e}const yn=[{header:"Task",accessor:e=>r("span",{class:"font-medium",children:e.task_name})},{header:"State",accessor:e=>r(W,{status:e.state})},{header:"Failures",accessor:e=>r("span",{class:e.failure_count>0?"text-danger tabular-nums":"tabular-nums",children:e.failure_count})},{header:"Threshold",accessor:e=>r("span",{class:"tabular-nums",children:e.threshold})},{header:"Window",accessor:e=>`${(e.window_ms/1e3).toFixed(0)}s`},{header:"Cooldown",accessor:e=>`${(e.cooldown_ms/1e3).toFixed(0)}s`},{header:"Last Failure",accessor:e=>r("span",{class:"text-muted",children:j(e.last_failure_at)})}];function kn(e){const{data:t,loading:n}=S("/api/circuit-breakers");return n&&!t?r(N,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Gt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Circuit Breakers"}),r("p",{class:"text-xs text-muted",children:"Automatic failure protection status"})]})]}),t!=null&&t.length?r(R,{columns:yn,data:t}):r(U,{message:"No circuit breakers configured",subtitle:"Circuit breakers activate when tasks fail repeatedly"})]})}const wn={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]"};function L({onClick:e,variant:t="primary",disabled:n,children:s,class:a=""}){return r("button",{type:"button",onClick:e,disabled:n,class:`inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-[13px] font-medium cursor-pointer transition-all duration-150 disabled:opacity-40 disabled:cursor-default disabled:shadow-none border-none ${wn[t]} ${a}`,children:s})}function nr({message:e,onConfirm:t,onCancel:n}){return r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-fade-in",onClick:n,children:r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-2xl dark:shadow-black/50 p-6 max-w-sm mx-4 border dark:border-white/[0.08] border-slate-200 animate-slide-in",onClick:s=>s.stopPropagation(),children:[r("div",{class:"flex items-start gap-3 mb-5",children:[r("div",{class:"p-2 rounded-lg bg-danger-dim shrink-0",children:r(Qr,{class:"w-5 h-5 text-danger",strokeWidth:2})}),r("p",{class:"text-sm dark:text-gray-200 text-slate-700 pt-1",children:e})]}),r("div",{class:"flex justify-end gap-2.5",children:[r(L,{variant:"ghost",onClick:n,children:"Cancel"}),r(L,{variant:"danger",onClick:t,children:"Confirm"})]})]})})}function sr({page:e,pageSize:t,itemCount:n,onPageChange:s}){return r("div",{class:"flex items-center justify-between px-4 py-3 text-[13px] text-muted border-t dark:border-white/[0.04] border-slate-100",children:[r("span",{children:["Showing ",e*t+1,"\\u2013",e*t+n," items"]}),r("div",{class:"flex gap-1.5",children:[r("button",{type:"button",onClick:()=>s(e-1),disabled:e===0,class:"inline-flex items-center gap-1 px-3 py-1.5 rounded-lg dark:bg-surface-3 bg-slate-100 dark:text-gray-300 text-slate-600 border dark:border-white/[0.06] border-slate-200 disabled:opacity-30 cursor-pointer disabled:cursor-default hover:enabled:dark:bg-surface-4 hover:enabled:bg-slate-200 transition-all duration-150 text-[13px]",children:[r(Ur,{class:"w-3.5 h-3.5"}),"Prev"]}),r("button",{type:"button",onClick:()=>s(e+1),disabled:n({error:n,items:s})).sort((n,s)=>s.items.length-n.items.length)}function Sn(e){const[t,n]=M(0),[s,a]=M(!1),[o,i]=M(!1),[l,u]=M(new Set),{data:d,loading:h,refetch:c}=S(`/api/dead-letters?limit=${o?200:Ne}&offset=${o?0:t*Ne}`,[t,o]),f=async _=>{try{await q(`/api/dead-letters/${_}/retry`),P("Dead letter retried","success"),c()}catch{P("Failed to retry dead letter","error")}},p=async _=>{let y=0;for(const E of _.items)try{await q(`/api/dead-letters/${E.id}/retry`),y++}catch{}P(`Retried ${y} of ${_.items.length} dead letters`,y>0?"success":"error"),c()},m=async()=>{a(!1);try{const _=await q("/api/dead-letters/purge");P(`Purged ${_.purged} dead letters`,"success"),c()}catch{P("Failed to purge dead letters","error")}},b=_=>{const y=new Set(l);y.has(_)?y.delete(_):y.add(_),u(y)},x=[{header:"ID",accessor:_=>r("span",{class:"font-mono text-xs text-accent-light",children:G(_.id)})},{header:"Original Job",accessor:_=>r("a",{href:`/jobs/${_.original_job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:G(_.original_job_id)})},{header:"Task",accessor:_=>r("span",{class:"font-medium",children:_.task_name})},{header:"Queue",accessor:"queue"},{header:"Error",accessor:_=>r("span",{class:"text-danger text-xs",title:_.error??"",children:_.error?_.error.length>50?`${_.error.slice(0,50)}…`:_.error:"—"}),className:"max-w-[250px]"},{header:"Retries",accessor:_=>r("span",{class:"text-warning tabular-nums",children:_.retry_count})},{header:"Failed At",accessor:_=>r("span",{class:"text-muted",children:j(_.failed_at)})},{header:"Actions",accessor:_=>r(L,{onClick:()=>f(_.id),children:[r(Se,{class:"w-3.5 h-3.5"}),"Retry"]})}];if(h&&!d)return r(N,{});const g=o&&d?$n(d):[];return r("div",{children:[r("div",{class:"flex items-center justify-between mb-6",children:[r("div",{class:"flex items-center gap-3",children:[r("div",{class:"p-2 rounded-lg bg-danger-dim",children:r(Qe,{class:"w-5 h-5 text-danger",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Dead Letters"}),r("p",{class:"text-xs text-muted",children:"Failed jobs that exhausted all retries"})]})]}),r("div",{class:"flex items-center gap-2",children:d&&d.length>0&&r(D,{children:[r("div",{class:"flex dark:bg-surface-3 bg-slate-100 rounded-lg p-1",children:[r("button",{type:"button",onClick:()=>{i(!1),n(0)},class:`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border-none cursor-pointer transition-all duration-150 ${o?"bg-transparent dark:text-gray-400 text-slate-500 hover:dark:text-white":"bg-accent text-white shadow-sm shadow-accent/20"}`,children:[r(Fr,{class:"w-3.5 h-3.5"}),"List"]}),r("button",{type:"button",onClick:()=>i(!0),class:`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border-none cursor-pointer transition-all duration-150 ${o?"bg-accent text-white shadow-sm shadow-accent/20":"bg-transparent dark:text-gray-400 text-slate-500 hover:dark:text-white"}`,children:[r(Or,{class:"w-3.5 h-3.5"}),"Group"]})]}),r(L,{variant:"danger",onClick:()=>a(!0),children:[r(Gr,{class:"w-3.5 h-3.5"}),"Purge All"]})]})})]}),d!=null&&d.length?o?r("div",{class:"space-y-3",children:g.map(_=>{const y=l.has(_.error);return r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 border dark:border-white/[0.06] border-slate-200 overflow-hidden",children:[r("div",{class:"flex items-center gap-3 px-5 py-4 cursor-pointer hover:dark:bg-white/[0.02] hover:bg-slate-50 transition-colors",onClick:()=>b(_.error),children:[y?r(Er,{class:"w-4 h-4 text-muted shrink-0"}):r(It,{class:"w-4 h-4 text-muted shrink-0"}),r("div",{class:"flex-1 min-w-0",children:r("span",{class:"text-danger text-sm font-mono truncate block",children:_.error})}),r("span",{class:"shrink-0 px-2.5 py-0.5 rounded-full text-xs font-semibold tabular-nums bg-danger/10 text-danger border border-danger/20",children:_.items.length}),r(L,{onClick:()=>{p(_)},children:[r(Se,{class:"w-3.5 h-3.5"}),"Retry All"]})]}),y&&r("div",{class:"border-t dark:border-white/[0.04] border-slate-100",children:r(R,{columns:x,data:_.items})})]},_.error)})}):r(R,{columns:x,data:d,children:r(sr,{page:t,pageSize:Ne,itemCount:d.length,onPageChange:n})}):r(U,{message:"No dead letters",subtitle:"All jobs are processing normally"}),s&&r(nr,{message:"Purge all dead letters? This cannot be undone.",onConfirm:m,onCancel:()=>a(!1)})]})}const Cn={pending:"#ffa726",running:"#42a5f5",complete:"#66bb6a",failed:"#ef5350",dead:"#ef5350",cancelled:"#a0a0b0"};function Mn({dag:e}){if(!e.nodes||e.nodes.length<=1)return null;const t=160,n=36,s=40,a=20,o={},i={};e.nodes.forEach(m=>{o[m.id]=[],i[m.id]=0}),e.edges.forEach(m=>{o[m.from]||(o[m.from]=[]),o[m.from].push(m.to),i[m.to]=(i[m.to]||0)+1});const l=[],u=new Set;let d=e.nodes.filter(m=>(i[m.id]||0)===0).map(m=>m.id);for(;d.length;){l.push([...d]);for(const b of d)u.add(b);const m=[];d.forEach(b=>{(o[b]||[]).forEach(x=>{!u.has(x)&&!m.includes(x)&&m.push(x)})}),d=m}e.nodes.forEach(m=>{u.has(m.id)||(l.push([m.id]),u.add(m.id))});const h={};for(const m of e.nodes)h[m.id]=m;const c={};let f=0,p=0;return l.forEach((m,b)=>{m.forEach((x,g)=>{const _=20+b*(t+s),y=20+g*(n+a);c[x]={x:_,y},f=Math.max(f,_+t+20),p=Math.max(p,y+n+20)})}),r("div",{class:"mt-4",children:[r("h3",{class:"text-sm text-muted mb-2",children:"Dependency Graph"}),r("div",{class:"dark:bg-surface-2 bg-white rounded-lg shadow-sm dark:shadow-black/30 p-4 overflow-x-auto border border-transparent dark:border-white/5",children:r("svg",{width:f,height:p,role:"img","aria-label":"Job dependency graph",children:[r("title",{children:"Job dependency graph"}),r("defs",{children:r("marker",{id:"arrow",viewBox:"0 0 10 10",refX:"10",refY:"5",markerWidth:"8",markerHeight:"8",orient:"auto",children:r("path",{d:"M0,0 L10,5 L0,10 z",fill:"#a0a0b0"})})}),e.edges.map((m,b)=>{const x=c[m.from],g=c[m.to];return!x||!g?null:r("line",{x1:x.x+t,y1:x.y+n/2,x2:g.x,y2:g.y+n/2,stroke:"#a0a0b0","stroke-width":"1.5",fill:"none","marker-end":"url(#arrow)"},b)}),e.nodes.map(m=>{const b=c[m.id];if(!b)return null;const x=Cn[m.status]||"#a0a0b0";return r("g",{class:"cursor-pointer",onClick:()=>te(`/jobs/${m.id}`),children:[r("rect",{x:b.x,y:b.y,width:t,height:n,fill:`${x}22`,stroke:x,"stroke-width":"1.5",rx:"6",ry:"6"}),r("text",{x:b.x+8,y:b.y+14,fill:x,"font-size":"10","font-weight":"600",children:m.status.toUpperCase()}),r("text",{x:b.x+8,y:b.y+28,"font-size":"10",fill:"#a0a0b0",children:m.task_name.length>18?m.task_name.slice(-18):m.task_name})]},m.id)})]})})]})}function Xe({progress:e}){return e==null?r("span",{class:"text-muted text-xs",children:"—"}):r("span",{class:"inline-flex items-center gap-2",children:[r("span",{class:"inline-block w-16 h-1.5 rounded-full dark:bg-white/[0.08] bg-slate-200 overflow-hidden",children:r("span",{class:"block h-full rounded-full bg-gradient-to-r from-accent to-accent-light transition-[width] duration-300",style:{width:`${e}%`}})}),r("span",{class:"text-xs tabular-nums text-muted",children:[e,"%"]})]})}const Tn=[{header:"Attempt",accessor:"attempt"},{header:"Error",accessor:"error",className:"max-w-xs truncate"},{header:"Failed At",accessor:e=>r("span",{class:"text-muted",children:j(e.failed_at)})}],Pn=[{header:"Time",accessor:e=>r("span",{class:"text-muted",children:j(e.logged_at)})},{header:"Level",accessor:e=>r(W,{status:e.level==="error"?"failed":e.level==="warning"?"pending":"complete"})},{header:"Message",accessor:"message"},{header:"Extra",accessor:e=>e.extra??"—",className:"max-w-[200px] truncate"}],jn=[{header:"Replay Job",accessor:e=>r("a",{href:`/jobs/${e.replay_job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:G(e.replay_job_id)})},{header:"Replayed At",accessor:e=>r("span",{class:"text-muted",children:j(e.replayed_at)})},{header:"Original Error",accessor:e=>e.original_error??"—",className:"max-w-[200px] truncate"},{header:"Replay Error",accessor:e=>e.replay_error??"—",className:"max-w-[200px] truncate"}];function Rn({id:e}){const{data:t,loading:n,refetch:s}=S(`/api/jobs/${e}`),{data:a}=S(`/api/jobs/${e}/errors`),{data:o}=S(`/api/jobs/${e}/logs`),{data:i}=S(`/api/jobs/${e}/replay-history`),{data:l}=S(`/api/jobs/${e}/dag`);if(n&&!t)return r(N,{});if(!t)return r(U,{message:`Job not found: ${e}`});const u=async()=>{try{const c=await q(`/api/jobs/${e}/cancel`);P(c.cancelled?"Job cancelled":"Failed to cancel job",c.cancelled?"success":"error"),s()}catch{P("Failed to cancel job","error")}},d=async()=>{try{const c=await q(`/api/jobs/${e}/replay`);P("Job replayed","success"),te(`/jobs/${c.replay_job_id}`)}catch{P("Failed to replay job","error")}},h={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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ir,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:["Job ",r("span",{class:"font-mono text-accent-light",children:G(t.id)})]}),r("p",{class:"text-xs text-muted",children:t.task_name})]})]}),r("div",{class:`dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-6 border dark:border-white/[0.06] border-slate-200 border-t-[3px] ${h[t.status]??"border-t-muted"}`,children:[r("div",{class:"grid grid-cols-[140px_1fr] gap-x-6 gap-y-3 text-[13px]",children:[r("span",{class:"text-muted font-medium",children:"ID"}),r("span",{class:"font-mono text-xs break-all dark:text-gray-300 text-slate-600",children:t.id}),r("span",{class:"text-muted font-medium",children:"Status"}),r("span",{children:r(W,{status:t.status})}),r("span",{class:"text-muted font-medium",children:"Task"}),r("span",{class:"font-medium",children:t.task_name}),r("span",{class:"text-muted font-medium",children:"Queue"}),r("span",{children:t.queue}),r("span",{class:"text-muted font-medium",children:"Priority"}),r("span",{children:t.priority}),r("span",{class:"text-muted font-medium",children:"Progress"}),r("span",{children:r(Xe,{progress:t.progress})}),r("span",{class:"text-muted font-medium",children:"Retries"}),r("span",{class:t.retry_count>0?"text-warning":"",children:[t.retry_count," / ",t.max_retries]}),r("span",{class:"text-muted font-medium",children:"Created"}),r("span",{class:"text-muted",children:j(t.created_at)}),r("span",{class:"text-muted font-medium",children:"Scheduled"}),r("span",{class:"text-muted",children:j(t.scheduled_at)}),r("span",{class:"text-muted font-medium",children:"Started"}),r("span",{class:"text-muted",children:t.started_at?j(t.started_at):"—"}),r("span",{class:"text-muted font-medium",children:"Completed"}),r("span",{class:"text-muted",children:t.completed_at?j(t.completed_at):"—"}),r("span",{class:"text-muted font-medium",children:"Timeout"}),r("span",{children:[(t.timeout_ms/1e3).toFixed(0),"s"]}),t.error&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Error"}),r("span",{class:"text-danger text-xs font-mono bg-danger-dim rounded px-2 py-1",children:t.error})]}),t.unique_key&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Unique Key"}),r("span",{class:"font-mono text-xs",children:t.unique_key})]}),t.metadata&&r(D,{children:[r("span",{class:"text-muted font-medium",children:"Metadata"}),r("span",{class:"font-mono text-xs",children:t.metadata})]})]}),r("div",{class:"flex gap-2.5 mt-5 pt-5 border-t dark:border-white/[0.06] border-slate-100",children:[t.status==="pending"&&r(L,{variant:"danger",onClick:u,children:"Cancel Job"}),r(L,{onClick:d,children:[r(Se,{class:"w-3.5 h-3.5"}),"Replay"]})]})]}),a&&a.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Error History ",r("span",{class:"text-muted font-normal",children:["(",a.length,")"]})]}),r(R,{columns:Tn,data:a})]}),o&&o.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Task Logs ",r("span",{class:"text-muted font-normal",children:["(",o.length,")"]})]}),r(R,{columns:Pn,data:o})]}),i&&i.length>0&&r("div",{class:"mt-6",children:[r("h3",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:["Replay History ",r("span",{class:"text-muted font-normal",children:["(",i.length,")"]})]}),r(R,{columns:jn,data:i})]}),l&&r(Mn,{dag:l}),r("div",{class:"mt-6",children:r("a",{href:"/jobs",class:"text-accent-light text-[13px] hover:underline",children:["←"," Back to jobs"]})})]})}const An={pending:{color:"text-warning",bg:"bg-warning-dim",border:"border-l-warning",icon:$e},running:{color:"text-info",bg:"bg-info-dim",border:"border-l-info",icon:Ht},completed:{color:"text-success",bg:"bg-success-dim",border:"border-l-success",icon:Ot},failed:{color:"text-danger",bg:"bg-danger-dim",border:"border-l-danger",icon:qt},dead:{color:"text-danger",bg:"bg-danger-dim",border:"border-l-danger",icon:Qe},cancelled:{color:"text-muted",bg:"bg-muted/10",border:"border-l-muted/40",icon:Nt}};function Ln({label:e,value:t,color:n}){const s=An[e],a=n??(s==null?void 0:s.color)??"text-accent-light",o=(s==null?void 0:s.bg)??"bg-accent-dim",i=(s==null?void 0:s.border)??"border-l-accent",l=(s==null?void 0:s.icon)??$e;return r("div",{class:`dark:bg-surface-2 bg-white rounded-xl p-5 shadow-sm dark:shadow-black/20 border-l-[3px] ${i} dark:border-t dark:border-r dark:border-b dark:border-white/[0.04] border border-slate-100 transition-all duration-150 hover:shadow-md hover:dark:shadow-black/30`,children:r("div",{class:"flex items-start justify-between",children:[r("div",{children:[r("div",{class:`text-3xl font-bold tabular-nums tracking-tight ${a}`,children:vn(t)}),r("div",{class:"text-xs text-muted uppercase mt-1.5 tracking-wider font-medium",children:e})]}),r("div",{class:`p-2 rounded-lg ${o}`,children:r(l,{class:`w-5 h-5 ${a}`,strokeWidth:1.8})})]})})}const Nn=["pending","running","completed","failed","dead","cancelled"];function ar({stats:e}){return r("div",{class:"grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-8",children:Nn.map(t=>r(Ln,{label:t,value:e[t]??0},t))})}const ze=20,En=[{header:"ID",accessor:e=>r("span",{class:"font-mono text-xs text-accent-light",children:G(e.id)})},{header:"Task",accessor:"task_name"},{header:"Queue",accessor:"queue"},{header:"Status",accessor:e=>r(W,{status:e.status})},{header:"Priority",accessor:"priority"},{header:"Progress",accessor:e=>r(Xe,{progress:e.progress})},{header:"Retries",accessor:e=>r("span",{class:e.retry_count>0?"text-warning":"text-muted",children:[e.retry_count,"/",e.max_retries]})},{header:"Created",accessor:e=>r("span",{class:"text-muted",children:j(e.created_at)})}];function Un(e,t){const n=new URLSearchParams;return n.set("limit",String(ze)),n.set("offset",String(t*ze)),e.status&&n.set("status",e.status),e.queue&&n.set("queue",e.queue),e.task&&n.set("task",e.task),e.metadata&&n.set("metadata",e.metadata),e.error&&n.set("error",e.error),e.created_after&&n.set("created_after",String(new Date(e.created_after).getTime())),e.created_before&&n.set("created_before",String(new Date(e.created_before).getTime())),`/api/jobs?${n}`}function In(e){const[t,n]=M({status:"",queue:"",task:"",metadata:"",error:"",created_after:"",created_before:""}),[s,a]=M(0),[o,i]=M(new Set),[l,u]=M(!1),{data:d}=S("/api/stats"),{data:h,loading:c,refetch:f}=S(Un(t,s),[t.status,t.queue,t.task,t.metadata,t.error,t.created_after,t.created_before,s]),p=(g,_)=>{n(y=>({...y,[g]:_})),a(0),i(new Set)},m=async()=>{u(!1);let g=0;for(const _ of o)try{(await q(`/api/jobs/${_}/cancel`)).cancelled&&g++}catch{}P(`Cancelled ${g} of ${o.size} jobs`,g>0?"success":"error"),i(new Set),f()},b=async()=>{let g=0;for(const _ of o)try{await q(`/api/jobs/${_}/replay`),g++}catch{}P(`Replayed ${g} of ${o.size} jobs`,g>0?"success":"error"),i(new Set),f()},x="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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(zt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Jobs"}),r("p",{class:"text-xs text-muted",children:"Browse and filter task queue jobs"})]})]}),d&&r(ar,{stats:d}),r("div",{class:"dark:bg-surface-2 bg-white rounded-xl p-4 mb-4 border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"flex items-center gap-2 mb-3 text-xs text-muted font-medium uppercase tracking-wider",children:[r(Jt,{class:"w-3.5 h-3.5"}),"Filters"]}),r("div",{class:"grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2.5",children:[r("select",{class:x,value:t.status,onChange:g=>p("status",g.target.value),children:[r("option",{value:"",children:"All statuses"}),r("option",{value:"pending",children:"Pending"}),r("option",{value:"running",children:"Running"}),r("option",{value:"complete",children:"Complete"}),r("option",{value:"failed",children:"Failed"}),r("option",{value:"dead",children:"Dead"}),r("option",{value:"cancelled",children:"Cancelled"})]}),r("input",{class:x,placeholder:"Queue\\u2026",value:t.queue,onInput:g=>p("queue",g.target.value)}),r("input",{class:x,placeholder:"Task\\u2026",value:t.task,onInput:g=>p("task",g.target.value)}),r("input",{class:x,placeholder:"Metadata\\u2026",value:t.metadata,onInput:g=>p("metadata",g.target.value)}),r("input",{class:x,placeholder:"Error text\\u2026",value:t.error,onInput:g=>p("error",g.target.value)}),r("input",{class:x,type:"date",title:"Created after",value:t.created_after,onInput:g=>p("created_after",g.target.value)}),r("input",{class:x,type:"date",title:"Created before",value:t.created_before,onInput:g=>p("created_before",g.target.value)})]})]}),o.size>0&&r("div",{class:"flex items-center gap-3 mb-4 px-4 py-3 rounded-xl dark:bg-accent/[0.08] bg-accent/[0.04] border dark:border-accent/20 border-accent/10",children:[r("span",{class:"text-sm font-medium dark:text-gray-200 text-slate-700",children:[o.size," job",o.size>1?"s":""," selected"]}),r("div",{class:"flex gap-2 ml-auto",children:[r(L,{variant:"danger",onClick:()=>u(!0),children:[r(Nt,{class:"w-3.5 h-3.5"}),"Cancel Selected"]}),r(L,{onClick:b,children:[r(Se,{class:"w-3.5 h-3.5"}),"Replay Selected"]}),r(L,{variant:"ghost",onClick:()=>i(new Set),children:[r(Vt,{class:"w-3.5 h-3.5"}),"Clear"]})]})]}),c&&!h?r(N,{}):h!=null&&h.length?r(R,{columns:En,data:h,onRowClick:g=>te(`/jobs/${g.id}`),selectable:!0,selectedKeys:o,rowKey:g=>g.id,onSelectionChange:i,children:r(sr,{page:s,pageSize:ze,itemCount:h.length,onPageChange:a})}):r(U,{message:"No jobs found",subtitle:"Try adjusting your filters"}),l&&r(nr,{message:`Cancel ${o.size} selected job${o.size>1?"s":""}? Only pending jobs can be cancelled.`,onConfirm:m,onCancel:()=>u(!1)})]})}const On=[{header:"Time",accessor:e=>r("span",{class:"text-muted",children:j(e.logged_at)})},{header:"Level",accessor:e=>r(W,{status:e.level==="error"?"failed":e.level==="warning"?"pending":"complete"})},{header:"Task",accessor:e=>r("span",{class:"font-medium",children:e.task_name})},{header:"Job",accessor:e=>r("a",{href:`/jobs/${e.job_id}`,class:"font-mono text-xs text-accent-light hover:underline",children:G(e.job_id)})},{header:"Message",accessor:"message"},{header:"Extra",accessor:e=>e.extra??"—",className:"max-w-[200px] truncate"}];function qn(e){const[t,n]=M(""),[s,a]=M(""),o=new URLSearchParams({limit:"100"});t&&o.set("task",t),s&&o.set("level",s);const{data:i,loading:l}=S(`/api/logs?${o}`,[t,s]),u="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 r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Bt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Logs"}),r("p",{class:"text-xs text-muted",children:"Structured task execution logs"})]})]}),r("div",{class:"flex gap-2.5 mb-5",children:[r("input",{class:`${u} w-44`,placeholder:"Filter by task\\u2026",value:t,onInput:d=>n(d.target.value)}),r("select",{class:u,value:s,onChange:d=>a(d.target.value),children:[r("option",{value:"",children:"All levels"}),r("option",{value:"error",children:"Error"}),r("option",{value:"warning",children:"Warning"}),r("option",{value:"info",children:"Info"}),r("option",{value:"debug",children:"Debug"})]})]}),l&&!i?r(N,{}):i!=null&&i.length?r(R,{columns:On,data:i}):r(U,{message:"No logs yet",subtitle:"Logs appear when tasks execute"})]})}function Wn({data:e}){const t=K(null);return X(()=>{const n=t.current;if(!n)return;const s=n.getContext("2d");if(!s)return;const a=window.devicePixelRatio||1,o=n.getBoundingClientRect();n.width=o.width*a,n.height=o.height*a,s.scale(a,a);const i=o.width,l=o.height;if(s.clearRect(0,0,i,l),!e.length){s.fillStyle="rgba(139,149,165,0.4)",s.font="12px -apple-system, sans-serif",s.textAlign="center",s.fillText("No timeseries data",i/2,l/2);return}const u={top:12,right:12,bottom:32,left:48},d=i-u.left-u.right,h=l-u.top-u.bottom,c=Math.max(...e.map(b=>b.success+b.failure),1),f=Math.max(3,d/e.length-2),p=Math.max(1,(d-f*e.length)/e.length);for(let b=0;b<=4;b++){const x=u.top+h*(1-b/4);s.strokeStyle="rgba(255,255,255,0.04)",s.lineWidth=1,s.beginPath(),s.moveTo(u.left,x),s.lineTo(i-u.right,x),s.stroke(),s.fillStyle="rgba(139,149,165,0.5)",s.font="10px -apple-system, sans-serif",s.textAlign="right",s.fillText(Math.round(c*b/4).toString(),u.left-6,x+3)}e.forEach((b,x)=>{const g=u.left+x*(f+p),_=b.success/c*h,y=b.failure/c*h;s.fillStyle="rgba(34,197,94,0.65)",s.beginPath();const E=u.top+h-_-y;s.roundRect(g,E,f,_,[2,2,0,0]),s.fill(),y>0&&(s.fillStyle="rgba(239,68,68,0.65)",s.beginPath(),s.roundRect(g,u.top+h-y,f,y,[0,0,2,2]),s.fill())}),s.fillStyle="rgba(139,149,165,0.5)",s.font="10px -apple-system, sans-serif",s.textAlign="center";const m=Math.min(6,e.length);for(let b=0;br("span",{class:"font-medium",children:e.task_name})},{header:"Total",accessor:e=>r("span",{class:"tabular-nums",children:e.count})},{header:"Success",accessor:e=>r("span",{class:"text-success tabular-nums",children:e.success_count})},{header:"Failures",accessor:e=>r("span",{class:e.failure_count>0?"text-danger tabular-nums":"text-muted tabular-nums",children:e.failure_count})},{header:"Avg",accessor:e=>r("span",{class:`tabular-nums ${xe(e.avg_ms,{good:100,warn:500})}`,children:[e.avg_ms,"ms"]})},{header:"P50",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.p50_ms,"ms"]})},{header:"P95",accessor:e=>r("span",{class:`tabular-nums ${xe(e.p95_ms,{good:200,warn:1e3})}`,children:[e.p95_ms,"ms"]})},{header:"P99",accessor:e=>r("span",{class:`tabular-nums ${xe(e.p99_ms,{good:500,warn:2e3})}`,children:[e.p99_ms,"ms"]})},{header:"Min",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.min_ms,"ms"]})},{header:"Max",accessor:e=>r("span",{class:`tabular-nums ${xe(e.max_ms,{good:1e3,warn:5e3})}`,children:[e.max_ms,"ms"]})}],Dn=[{label:"1h",seconds:3600},{label:"6h",seconds:21600},{label:"24h",seconds:86400}];function zn(e){const[t,n]=M(3600),{data:s,loading:a}=S(`/api/metrics?since=${t}`,[t]),{data:o}=S(`/api/metrics/timeseries?since=${t}&bucket=${t<=3600?60:t<=21600?300:900}`,[t]),i=s?Object.entries(s).map(([l,u])=>({task_name:l,...u})):[];return r("div",{children:[r("div",{class:"flex items-center justify-between mb-6",children:[r("div",{class:"flex items-center gap-3",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ut,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Metrics"}),r("p",{class:"text-xs text-muted",children:"Task performance and throughput"})]})]}),r("div",{class:"flex gap-1 dark:bg-surface-3 bg-slate-100 rounded-lg p-1",children:Dn.map(l=>r("button",{type:"button",onClick:()=>n(l.seconds),class:`px-3 py-1.5 text-xs font-medium rounded-md border-none cursor-pointer transition-all duration-150 ${t===l.seconds?"bg-accent text-white shadow-sm shadow-accent/20":"bg-transparent dark:text-gray-400 text-slate-500 hover:dark:text-white hover:text-slate-900"}`,children:l.label},l.label))})]}),o&&o.length>0&&r(Wn,{data:o}),a&&!s?r(N,{}):i.length?r(R,{columns:Fn,data:i}):r(U,{message:"No metrics yet",subtitle:"Run some tasks to see performance data"})]})}function Hn({data:e}){const t=K(null);X(()=>{const s=t.current;if(!s)return;const a=s.getContext("2d");if(!a)return;const o=window.devicePixelRatio||1,i=s.getBoundingClientRect();s.width=i.width*o,s.height=i.height*o,a.scale(o,o);const l=i.width,u=i.height;if(a.clearRect(0,0,l,u),e.length<2){a.fillStyle="rgba(139,149,165,0.4)",a.font="12px -apple-system, sans-serif",a.textAlign="center",a.fillText("Collecting data…",l/2,u/2);return}const d=Math.max(...e,1),h={top:12,right:12,bottom:24,left:44},c=l-h.left-h.right,f=u-h.top-h.bottom;for(let m=0;m<=4;m++){const b=h.top+f*(1-m/4);a.strokeStyle="rgba(255,255,255,0.04)",a.lineWidth=1,a.beginPath(),a.moveTo(h.left,b),a.lineTo(l-h.right,b),a.stroke(),a.fillStyle="rgba(139,149,165,0.5)",a.font="10px -apple-system, sans-serif",a.textAlign="right",a.fillText((d*m/4).toFixed(1),h.left-6,b+3)}const p=a.createLinearGradient(0,h.top,0,h.top+f);if(p.addColorStop(0,"rgba(34,197,94,0.2)"),p.addColorStop(1,"rgba(34,197,94,0.01)"),a.beginPath(),a.moveTo(h.left,h.top+f),e.forEach((m,b)=>{const x=h.left+b/(e.length-1)*c,g=h.top+f*(1-m/d);a.lineTo(x,g)}),a.lineTo(h.left+c,h.top+f),a.closePath(),a.fillStyle=p,a.fill(),a.beginPath(),e.forEach((m,b)=>{const x=h.left+b/(e.length-1)*c,g=h.top+f*(1-m/d);b===0?a.moveTo(x,g):a.lineTo(x,g)}),a.strokeStyle="#22c55e",a.lineWidth=2,a.lineJoin="round",a.stroke(),e.length>0){const m=h.left+c,b=h.top+f*(1-e[e.length-1]/d);a.beginPath(),a.arc(m,b,3,0,Math.PI*2),a.fillStyle="#22c55e",a.fill(),a.beginPath(),a.arc(m,b,5,0,Math.PI*2),a.strokeStyle="rgba(34,197,94,0.3)",a.lineWidth=2,a.stroke()}},[e]);const n=e.length>0?e[e.length-1]:0;return r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-5 mb-6 border dark:border-white/[0.06] border-slate-200",children:[r("div",{class:"flex items-center justify-between mb-4",children:[r("div",{class:"flex items-center gap-2",children:[r(Vr,{class:"w-4 h-4 text-success",strokeWidth:2}),r("h3",{class:"text-sm font-medium dark:text-gray-300 text-slate-600",children:"Throughput"})]}),r("span",{class:"text-xl font-bold tabular-nums text-success",children:[n.toFixed(1)," ",r("span",{class:"text-xs font-normal text-muted",children:"jobs/s"})]})]}),r("canvas",{ref:t,class:"w-full",style:{height:"180px"}})]})}const Bn=[{header:"ID",accessor:e=>r("span",{class:"font-mono text-xs text-accent-light",children:G(e.id)})},{header:"Task",accessor:"task_name"},{header:"Queue",accessor:"queue"},{header:"Status",accessor:e=>r(W,{status:e.status})},{header:"Progress",accessor:e=>r(Xe,{progress:e.progress})},{header:"Created",accessor:e=>r("span",{class:"text-muted",children:j(e.created_at)})}];function Jn(e){const{data:t,loading:n}=S("/api/stats"),{data:s}=S("/api/jobs?limit=10"),a=K(0),o=K([]);if(t){const i=t.completed||0,l=J.value||5e3;let u=0;a.current>0&&(u=parseFloat(((i-a.current)/(l/1e3)).toFixed(1))),a.current=i,o.current=[...o.current.slice(-59),u]}return n&&!t?r(N,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Ft,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Overview"}),r("p",{class:"text-xs text-muted",children:"Real-time queue status"})]})]}),t&&r(ar,{stats:t}),r(Hn,{data:o.current}),r("div",{class:"flex items-center gap-2 mb-4 mt-8",children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700",children:"Recent Jobs"}),r("span",{class:"text-xs text-muted",children:"(latest 10)"})]}),s!=null&&s.length?r(R,{columns:Bn,data:s,onRowClick:i=>te(`/jobs/${i.id}`)}):null]})}function Gn(e){const{data:t,loading:n,refetch:s}=S("/api/stats/queues"),{data:a,refetch:o}=S("/api/queues/paused"),i=new Set(a??[]),l=t?Object.entries(t).map(([c,f])=>({name:c,pending:f.pending??0,running:f.running??0,paused:i.has(c)})):[],u=async c=>{try{await q(`/api/queues/${encodeURIComponent(c)}/pause`),P(`Queue "${c}" paused`,"success"),s(),o()}catch{P(`Failed to pause queue "${c}"`,"error")}},d=async c=>{try{await q(`/api/queues/${encodeURIComponent(c)}/resume`),P(`Queue "${c}" resumed`,"success"),s(),o()}catch{P(`Failed to resume queue "${c}"`,"error")}},h=[{header:"Queue",accessor:c=>r("span",{class:"font-medium",children:c.name})},{header:"Pending",accessor:c=>r("span",{class:"text-warning tabular-nums font-medium",children:c.pending})},{header:"Running",accessor:c=>r("span",{class:"text-info tabular-nums font-medium",children:c.running})},{header:"Status",accessor:c=>r(W,{status:c.paused?"paused":"active"})},{header:"Actions",accessor:c=>c.paused?r(L,{onClick:()=>d(c.name),children:[r(Ht,{class:"w-3.5 h-3.5"}),"Resume"]}):r(L,{variant:"ghost",onClick:()=>u(c.name),children:[r(zr,{class:"w-3.5 h-3.5"}),"Pause"]})}];return n&&!t?r(N,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Dt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Queue Management"}),r("p",{class:"text-xs text-muted",children:"Monitor and control individual queues"})]})]}),l.length?r(R,{columns:h,data:l}):r(U,{message:"No queues found",subtitle:"Queues appear when tasks are enqueued"})]})}const Vn=[{header:"Name",accessor:e=>r("span",{class:"font-medium",children:e.name})},{header:"Scope",accessor:e=>r(W,{status:e.scope})},{header:"Health",accessor:e=>r(W,{status:e.health})},{header:"Init (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.init_duration_ms.toFixed(1),"ms"]})},{header:"Recreations",accessor:e=>r("span",{class:`tabular-nums ${e.recreations>0?"text-warning":"text-muted"}`,children:e.recreations})},{header:"Dependencies",accessor:e=>e.depends_on.length?r("span",{class:"text-xs",children:e.depends_on.join(", ")}):r("span",{class:"text-muted",children:"—"})},{header:"Pool",accessor:e=>e.pool?r("span",{class:"text-xs tabular-nums",children:[r("span",{class:"text-info",children:e.pool.active}),"/",e.pool.size," active,"," ",r("span",{class:"text-muted",children:[e.pool.idle," idle"]})]}):r("span",{class:"text-muted",children:"—"})}];function Qn(e){const{data:t,loading:n}=S("/api/resources");return n&&!t?r(N,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Et,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Resources"}),r("p",{class:"text-xs text-muted",children:"Worker dependency injection runtime"})]})]}),t!=null&&t.length?r(R,{columns:Vn,data:t}):r(U,{message:"No resources registered",subtitle:"Resources appear when workers start with DI configuration"})]})}const Yn=[{header:"Handler",accessor:e=>r("span",{class:"font-medium",children:e.handler})},{header:"Reconstructions",accessor:e=>r("span",{class:"tabular-nums",children:e.reconstructions})},{header:"Avg (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.avg_ms.toFixed(1),"ms"]})},{header:"Errors",accessor:e=>r("span",{class:`tabular-nums ${e.errors>0?"text-danger font-medium":"text-muted"}`,children:e.errors})}],Zn=[{header:"Strategy",accessor:e=>r("span",{class:"font-medium uppercase text-xs tracking-wide",children:e.strategy})},{header:"Count",accessor:e=>r("span",{class:"tabular-nums",children:e.count})},{header:"Avg (ms)",accessor:e=>r("span",{class:"tabular-nums text-muted",children:[e.avg_ms.toFixed(1),"ms"]})}];function Xn(e){const{data:t,loading:n}=S("/api/proxy-stats"),{data:s,loading:a}=S("/api/interception-stats"),o=t?Object.entries(t).map(([l,u])=>({handler:l,...u})):[],i=s?Object.entries(s).map(([l,u])=>({strategy:l,...u})):[];return r("div",{children:[r("div",{class:"flex items-center gap-3 mb-8",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Wt,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"System Internals"}),r("p",{class:"text-xs text-muted",children:"Proxy reconstruction and interception metrics"})]})]}),r("div",{class:"mb-8",children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:"Proxy Reconstruction"}),n&&!t?r(N,{}):o.length?r(R,{columns:Yn,data:o}):r(U,{message:"No proxy stats available",subtitle:"Stats appear when proxy handlers are used"})]}),r("div",{children:[r("h2",{class:"text-sm font-semibold dark:text-gray-200 text-slate-700 mb-3",children:"Interception"}),a&&!s?r(N,{}):i.length?r(R,{columns:Zn,data:i}):r(U,{message:"No interception stats available",subtitle:"Stats appear when argument interception is enabled"})]})]})}function Kn(e){const{data:t,loading:n}=S("/api/workers"),{data:s}=S("/api/stats");return n&&!t?r(N,{}):r("div",{children:[r("div",{class:"flex items-center gap-3 mb-6",children:[r("div",{class:"p-2 rounded-lg dark:bg-surface-3 bg-slate-100",children:r(Fe,{class:"w-5 h-5 text-accent",strokeWidth:1.8})}),r("div",{children:[r("h1",{class:"text-lg font-semibold dark:text-white text-slate-900",children:"Workers"}),r("p",{class:"text-xs text-muted",children:[(t==null?void 0:t.length)??0," active ","·"," ",(s==null?void 0:s.running)??0," running jobs"]})]})]}),t!=null&&t.length?r("div",{class:"grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-4",children:t.map(a=>r("div",{class:"dark:bg-surface-2 bg-white rounded-xl shadow-sm dark:shadow-black/20 p-5 border dark:border-white/[0.06] border-slate-200 transition-all duration-150 hover:shadow-md hover:dark:shadow-black/30",children:[r("div",{class:"flex items-center gap-2 mb-3",children:[r("span",{class:"w-2 h-2 rounded-full bg-success shadow-sm shadow-success/40"}),r("span",{class:"font-mono text-xs text-accent-light font-medium",children:a.worker_id})]}),r("div",{class:"space-y-2 text-[13px]",children:[r("div",{class:"flex items-center gap-2 text-muted",children:[r(Fe,{class:"w-3.5 h-3.5"}),"Queues: ",r("span",{class:"dark:text-gray-200 text-slate-700",children:a.queues})]}),r("div",{class:"flex items-center gap-2 text-muted",children:[r($e,{class:"w-3.5 h-3.5"}),"Last heartbeat:"," ",r("span",{class:"dark:text-gray-200 text-slate-700",children:j(a.last_heartbeat)})]}),r("div",{class:"flex items-center gap-2 text-muted",children:[r($e,{class:"w-3.5 h-3.5"}),"Registered:"," ",r("span",{class:"dark:text-gray-200 text-slate-700",children:j(a.registered_at)})]}),a.tags&&r("div",{class:"flex items-center gap-2 text-muted",children:[r(Jr,{class:"w-3.5 h-3.5"}),"Tags: ",r("span",{class:"dark:text-gray-200 text-slate-700",children:a.tags})]})]})]},a.worker_id))}):r(U,{message:"No active workers",subtitle:"Workers will appear when they connect"})]})}function es(){return r(hn,{children:[r(Lt,{children:[r(Jn,{path:"/"}),r(In,{path:"/jobs"}),r(Rn,{path:"/jobs/:id"}),r(zn,{path:"/metrics"}),r(qn,{path:"/logs"}),r(Kn,{path:"/workers"}),r(kn,{path:"/circuit-breakers"}),r(Sn,{path:"/dead-letters"}),r(Qn,{path:"/resources"}),r(Gn,{path:"/queues"}),r(Xn,{path:"/system"})]}),r(_n,{})]})}hr(r(es,{}),document.getElementById("app")); +