From a33a2bc111d8416010ac30b4a135900b42c3eb99 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 6 Feb 2022 19:47:52 +0100 Subject: [PATCH 001/258] Add initial version of dashboard --- README.md | 21 + composer.json | 4 +- dashboard/.gitignore | 4 + dashboard/.prettierrc.js | 6 + dashboard/README.md | 7 + dashboard/dist/assets/index.1ecbaa60.css | 1 + dashboard/dist/assets/index.481c4ac3.js | 1 + dashboard/dist/assets/vendor.f52c9be3.js | 100 + dashboard/dist/crossword.png | Bin 0 -> 43694 bytes dashboard/dist/dot-grid.png | Bin 0 -> 25993 bytes dashboard/dist/favicon.ico | Bin 0 -> 4286 bytes dashboard/dist/index.html | 16 + dashboard/dist/manifest.json | 16 + dashboard/dist/pw_maze_white.png | Bin 0 -> 600 bytes dashboard/index.html | 13 + dashboard/package-lock.json | 2828 +++++++++++++++++ dashboard/package.json | 22 + dashboard/postcss.config.js | 6 + dashboard/public/crossword.png | Bin 0 -> 43694 bytes dashboard/public/dot-grid.png | Bin 0 -> 25993 bytes dashboard/public/favicon.ico | Bin 0 -> 4286 bytes dashboard/public/pw_maze_white.png | Bin 0 -> 600 bytes dashboard/src/App.vue | 32 + dashboard/src/api.js | 46 + dashboard/src/assets/logo.png | Bin 0 -> 6849 bytes dashboard/src/components/Dashboard.vue | 103 + dashboard/src/components/Failed.vue | 23 + dashboard/src/components/FilterCard.vue | 80 + dashboard/src/components/Icon.vue | 38 + dashboard/src/components/Menu.vue | 31 + dashboard/src/components/Overview.vue | 207 ++ dashboard/src/components/Queued.vue | 23 + dashboard/src/components/Recent.vue | 23 + dashboard/src/components/Spinner.vue | 22 + dashboard/src/components/Status.vue | 37 + dashboard/src/components/Task.vue | 104 + dashboard/src/components/TaskRowSpinner.vue | 28 + dashboard/src/index.css | 3 + dashboard/src/main.js | 88 + dashboard/tailwind.config.js | 10 + dashboard/vite.config.js | 11 + ...1140_create_stackkit_cloud_tasks_table.php | 41 + src/Authenticate.php | 11 + src/CloudTasks.php | 41 + src/CloudTasksApiController.php | 163 + src/CloudTasksQueue.php | 2 + src/CloudTasksServiceProvider.php | 77 + src/MonitoringService.php | 118 + src/StackkitCloudTask.php | 91 + src/TaskHandler.php | 25 + src/TaskMetadata.php | 53 + views/layout.blade.php | 28 + 52 files changed, 4602 insertions(+), 2 deletions(-) create mode 100644 dashboard/.gitignore create mode 100644 dashboard/.prettierrc.js create mode 100644 dashboard/README.md create mode 100644 dashboard/dist/assets/index.1ecbaa60.css create mode 100644 dashboard/dist/assets/index.481c4ac3.js create mode 100644 dashboard/dist/assets/vendor.f52c9be3.js create mode 100644 dashboard/dist/crossword.png create mode 100644 dashboard/dist/dot-grid.png create mode 100644 dashboard/dist/favicon.ico create mode 100644 dashboard/dist/index.html create mode 100644 dashboard/dist/manifest.json create mode 100644 dashboard/dist/pw_maze_white.png create mode 100644 dashboard/index.html create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/postcss.config.js create mode 100644 dashboard/public/crossword.png create mode 100644 dashboard/public/dot-grid.png create mode 100644 dashboard/public/favicon.ico create mode 100644 dashboard/public/pw_maze_white.png create mode 100644 dashboard/src/App.vue create mode 100644 dashboard/src/api.js create mode 100644 dashboard/src/assets/logo.png create mode 100644 dashboard/src/components/Dashboard.vue create mode 100644 dashboard/src/components/Failed.vue create mode 100644 dashboard/src/components/FilterCard.vue create mode 100644 dashboard/src/components/Icon.vue create mode 100644 dashboard/src/components/Menu.vue create mode 100644 dashboard/src/components/Overview.vue create mode 100644 dashboard/src/components/Queued.vue create mode 100644 dashboard/src/components/Recent.vue create mode 100644 dashboard/src/components/Spinner.vue create mode 100644 dashboard/src/components/Status.vue create mode 100644 dashboard/src/components/Task.vue create mode 100644 dashboard/src/components/TaskRowSpinner.vue create mode 100644 dashboard/src/index.css create mode 100644 dashboard/src/main.js create mode 100644 dashboard/tailwind.config.js create mode 100644 dashboard/vite.config.js create mode 100644 migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php create mode 100644 src/Authenticate.php create mode 100644 src/CloudTasks.php create mode 100644 src/CloudTasksApiController.php create mode 100644 src/MonitoringService.php create mode 100644 src/StackkitCloudTask.php create mode 100644 src/TaskMetadata.php create mode 100644 views/layout.blade.php diff --git a/README.md b/README.md index 76d5f15..2fbdf30 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,27 @@ Please check the table below on what the values mean and what their value should |`STACKKIT_CLOUD_TASKS_QUEUE`|The queue a job will be added to|`emails` |`STACKKIT_CLOUD_TASKS_SERVICE_EMAIL`|The email address of the AppEngine service account. Important, it should have the *Cloud Tasks Enqueuer* role. This is used for securing the handler.|`my-service-account@appspot.gserviceaccount.com` +## Dashboard + +The package comes with a dashboard that can be used to monitor all queued jobs. + +To make use of it, publish its assets: + +``` +php artisan vendor:publish --tag=cloud-tasks-assets +``` + +We expose a dashboard at the /cloud-tasks URI. By default, you will only be able to access this dashboard in the local environment. However, within your app/Providers/AppServiceProvider.php file, there is an authorization gate definition. This authorization gate controls access to Cloud Tasks in non-local environments. You are free to modify this gate as needed to restrict access to your Cloud Tasks installation: + + +```php +Gate::define('viewCloudTasks', function ($user) { + return in_array($user->email, [ + 'me@example.com', + ]); +}); +``` + # Authentication Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable with a path to the credentials file. diff --git a/composer.json b/composer.json index 959581a..3d37742 100644 --- a/composer.json +++ b/composer.json @@ -9,8 +9,8 @@ ], "require": { "ext-json": "*", - "google/cloud-tasks": "^v1.9", - "firebase/php-jwt": "^5.5", + "google/cloud-tasks": "^1.6", + "firebase/php-jwt": "^5.2", "phpseclib/phpseclib": "~2.0" }, "require-dev": { diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..a84704d --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,4 @@ +node_modules +.DS_Store +dist-ssr +*.local \ No newline at end of file diff --git a/dashboard/.prettierrc.js b/dashboard/.prettierrc.js new file mode 100644 index 0000000..0614ee7 --- /dev/null +++ b/dashboard/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + trailingComma: 'es5', + tabWidth: 2, + semi: false, + singleQuote: true, +} diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..c0793a8 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,7 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + + + +
+ + + diff --git a/dashboard/dist/manifest.json b/dashboard/dist/manifest.json new file mode 100644 index 0000000..3756dd9 --- /dev/null +++ b/dashboard/dist/manifest.json @@ -0,0 +1,16 @@ +{ + "index.html": { + "file": "assets/index.481c4ac3.js", + "src": "index.html", + "isEntry": true, + "imports": [ + "_vendor.f52c9be3.js" + ], + "css": [ + "assets/index.1ecbaa60.css" + ] + }, + "_vendor.f52c9be3.js": { + "file": "assets/vendor.f52c9be3.js" + } +} \ No newline at end of file diff --git a/dashboard/dist/pw_maze_white.png b/dashboard/dist/pw_maze_white.png new file mode 100644 index 0000000000000000000000000000000000000000..66464831c0c4389471dfb0ba94981005f77d12a6 GIT binary patch literal 600 zcmV-e0;m0nP)gws~>GJaO^z`)g_4W4l_Q@I-8vpR-ww`TUoSA}Vkbc?jsbu<&5t1W$|wV}QxLbi{VYj4Y( z`Wq08&8%9Lq<*SnC&26reul>-j;)mzOK*usBd~z4+s{{H3 zD>h_8R#wH+sWAAKse2^!mr1pY=7g9QZ-Rhz*OSi(^SM5HLr070t0mQ;oNx2Jiqsml z|Cf-?aF&&SgS_)psZ(#@&N&dUXXJ9$VWGMczStPj!hpyHPDA< zl*~{c(JqZ;n=T7GWgTAh1l@1gYVX@0`RFl`?7lTBs@~GXaAZeLm&shrznR>m^kjxt7JFuzI^0K7(u6l$&=SMg64tmFQP(razaoluGO^R+;~))Jg0fxzQ-%K4f?}b` z?hV(Yr_ib$+*z^5!s##cv)T(StdAMgjy8k%GxG53^2yf<;{FDUu5LEynSYw#AlO}f mzkdv|erYfIv3Tu9Ab$btOBd6f7(Ds_0000 + + + + + + Vite App + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..bde1ef3 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2828 @@ +{ + "name": "cloud-tasks-dashboard", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "version": "0.0.0", + "dependencies": { + "vue": "^3.2.25", + "vue-router": "^4.0.12", + "vue3-popper": "^1.4.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^2.0.0", + "autoprefixer": "^10.4.2", + "postcss": "^8.4.5", + "prettier": "2.5.1", + "tailwindcss": "^3.0.18", + "vite": "^2.7.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", + "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", + "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz", + "integrity": "sha512-AZ78WxvFMYd8JmM/GBV6a6SGGTU0GgN/0/4T+FnMMsLzFEzTeAUwuraapy50ifHZsC+G5SvWs86bvaCPTneFlA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "vite": "^2.5.10", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.29.tgz", + "integrity": "sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==", + "dependencies": { + "@babel/parser": "^7.16.4", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "source-map": "^0.6.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz", + "integrity": "sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==", + "dependencies": { + "@vue/compiler-core": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz", + "integrity": "sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==", + "dependencies": { + "@babel/parser": "^7.16.4", + "@vue/compiler-core": "3.2.29", + "@vue/compiler-dom": "3.2.29", + "@vue/compiler-ssr": "3.2.29", + "@vue/reactivity-transform": "3.2.29", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.25.7", + "postcss": "^8.1.10", + "source-map": "^0.6.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz", + "integrity": "sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==", + "dependencies": { + "@vue/compiler-dom": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.0.0-beta.21.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.0.0-beta.21.1.tgz", + "integrity": "sha512-FqC4s3pm35qGVeXRGOjTsRzlkJjrBLriDS9YXbflHLsfA9FrcKzIyWnLXoNm+/7930E8rRakXuAc2QkC50swAw==" + }, + "node_modules/@vue/reactivity": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.29.tgz", + "integrity": "sha512-Ryhb6Gy62YolKXH1gv42pEqwx7zs3n8gacRVZICSgjQz8Qr8QeCcFygBKYfJm3o1SccR7U+bVBQDWZGOyG1k4g==", + "dependencies": { + "@vue/shared": "3.2.29" + } + }, + "node_modules/@vue/reactivity-transform": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz", + "integrity": "sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==", + "dependencies": { + "@babel/parser": "^7.16.4", + "@vue/compiler-core": "3.2.29", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.25.7" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.29.tgz", + "integrity": "sha512-VMvQuLdzoTGmCwIKTKVwKmIL0qcODIqe74JtK1pVr5lnaE0l25hopodmPag3RcnIcIXe+Ye3B2olRCn7fTCgig==", + "dependencies": { + "@vue/reactivity": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.29.tgz", + "integrity": "sha512-YJgLQLwr+SQyORzTsBQLL5TT/5UiV83tEotqjL7F9aFDIQdFBTCwpkCFvX9jqwHoyi9sJqM9XtTrMcc8z/OjPA==", + "dependencies": { + "@vue/runtime-core": "3.2.29", + "@vue/shared": "3.2.29", + "csstype": "^2.6.8" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.29.tgz", + "integrity": "sha512-lpiYx7ciV7rWfJ0tPkoSOlLmwqBZ9FTmQm33S+T4g0j1fO/LmhJ9b9Ctl1o5xvIFVDk9QkSUWANZn7H2pXuxVw==", + "dependencies": { + "@vue/compiler-ssr": "3.2.29", + "@vue/shared": "3.2.29" + }, + "peerDependencies": { + "vue": "3.2.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.29.tgz", + "integrity": "sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==" + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", + "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.2.tgz", + "integrity": "sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ==", + "dev": true, + "dependencies": { + "browserslist": "^4.19.1", + "caniuse-lite": "^1.0.30001297", + "fraction.js": "^4.1.2", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", + "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", + "dev": true, + "dependencies": { + "caniuse-lite": "^1.0.30001286", + "electron-to-chromium": "^1.4.17", + "escalade": "^3.1.1", + "node-releases": "^2.0.1", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001304", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001304.tgz", + "integrity": "sha512-bdsfZd6K6ap87AGqSHJP/s1V+U6Z5lyrcbBu3ovbCCf8cSYpwTtGrCBObMpJqwxfTbLW6YTIdbb1jEeTelcpYQ==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "2.6.19", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", + "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "node_modules/defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, + "node_modules/detective": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", + "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, + "dependencies": { + "acorn-node": "^1.6.1", + "defined": "^1.0.0", + "minimist": "^1.1.1" + }, + "bin": { + "detective": "bin/detective.js" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.57", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.57.tgz", + "integrity": "sha512-FNC+P5K1n6pF+M0zIK+gFCoXcJhhzDViL3DRIGy2Fv5PohuSES1JHR7T+GlwxSxlzx4yYbsuzCZvHxcBSRCIOw==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz", + "integrity": "sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "optionalDependencies": { + "esbuild-android-arm64": "0.13.15", + "esbuild-darwin-64": "0.13.15", + "esbuild-darwin-arm64": "0.13.15", + "esbuild-freebsd-64": "0.13.15", + "esbuild-freebsd-arm64": "0.13.15", + "esbuild-linux-32": "0.13.15", + "esbuild-linux-64": "0.13.15", + "esbuild-linux-arm": "0.13.15", + "esbuild-linux-arm64": "0.13.15", + "esbuild-linux-mips64le": "0.13.15", + "esbuild-linux-ppc64le": "0.13.15", + "esbuild-netbsd-64": "0.13.15", + "esbuild-openbsd-64": "0.13.15", + "esbuild-sunos-64": "0.13.15", + "esbuild-windows-32": "0.13.15", + "esbuild-windows-64": "0.13.15", + "esbuild-windows-arm64": "0.13.15" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz", + "integrity": "sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/esbuild-darwin-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz", + "integrity": "sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz", + "integrity": "sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz", + "integrity": "sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz", + "integrity": "sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-linux-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz", + "integrity": "sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz", + "integrity": "sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz", + "integrity": "sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz", + "integrity": "sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz", + "integrity": "sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz", + "integrity": "sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz", + "integrity": "sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ] + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz", + "integrity": "sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/esbuild-sunos-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz", + "integrity": "sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/esbuild-windows-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz", + "integrity": "sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz", + "integrity": "sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz", + "integrity": "sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "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==" + }, + "node_modules/fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz", + "integrity": "sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "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 + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/lilconfig": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", + "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dependencies": { + "sourcemap-codec": "^1.4.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "dependencies": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", + "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", + "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.5", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", + "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", + "dependencies": { + "nanoid": "^3.1.30", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/postcss-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", + "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.1.tgz", + "integrity": "sha512-c/9XYboIbSEUZpiD1UQD0IKiUe8n9WHYV7YFe7X7J+ZwCsEKkUJSFWjS9hBU1RR9THR7jMXst8sxiqP0jjo2mg==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.4", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", + "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.6" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", + "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prettier": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", + "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.66.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.66.1.tgz", + "integrity": "sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.18.tgz", + "integrity": "sha512-ihPTpEyA5ANgZbwKlgrbfnzOp9R5vDHFWmqxB1PT8NwOGCOFVVMl+Ps1cQQ369acaqqf1BEF77roCwK0lvNmTw==", + "dev": true, + "dependencies": { + "arg": "^5.0.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "cosmiconfig": "^7.0.1", + "detective": "^5.2.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "normalize-path": "^3.0.0", + "object-hash": "^2.2.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.0", + "postcss-nested": "5.0.6", + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.21.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "autoprefixer": "^10.0.2", + "postcss": "^8.0.9" + } + }, + "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, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "node_modules/vite": { + "version": "2.7.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.7.13.tgz", + "integrity": "sha512-Mq8et7f3aK0SgSxjDNfOAimZGW9XryfHRa/uV0jseQSilg+KhYDSoNb9h1rknOy6SuMkvNDLKCYAYYUMCE+IgQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.13.12", + "postcss": "^8.4.5", + "resolve": "^1.20.0", + "rollup": "^2.59.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": ">=12.2.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "less": "*", + "sass": "*", + "stylus": "*" + }, + "peerDependenciesMeta": { + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.29.tgz", + "integrity": "sha512-cFIwr7LkbtCRanjNvh6r7wp2yUxfxeM2yPpDQpAfaaLIGZSrUmLbNiSze9nhBJt5MrZ68Iqt0O5scwAMEVxF+Q==", + "dependencies": { + "@vue/compiler-dom": "3.2.29", + "@vue/compiler-sfc": "3.2.29", + "@vue/runtime-dom": "3.2.29", + "@vue/server-renderer": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "node_modules/vue-router": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.12.tgz", + "integrity": "sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==", + "dependencies": { + "@vue/devtools-api": "^6.0.0-beta.18" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue3-popper": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/vue3-popper/-/vue3-popper-1.4.1.tgz", + "integrity": "sha512-pmct5vumtvbK8MmUs4oFY+3Al1glU34QXWcIPK4WJhRo/Kp85kxD0j70cNofNBqHYwhY5D7xJ6Yhkwf/5x9w7Q==", + "dependencies": { + "@popperjs/core": "^2.9.2", + "debounce": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "vue": "^3.2.20" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", + "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==" + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@popperjs/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", + "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "@vitejs/plugin-vue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz", + "integrity": "sha512-AZ78WxvFMYd8JmM/GBV6a6SGGTU0GgN/0/4T+FnMMsLzFEzTeAUwuraapy50ifHZsC+G5SvWs86bvaCPTneFlA==", + "dev": true, + "requires": {} + }, + "@vue/compiler-core": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.29.tgz", + "integrity": "sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==", + "requires": { + "@babel/parser": "^7.16.4", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "source-map": "^0.6.1" + } + }, + "@vue/compiler-dom": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz", + "integrity": "sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==", + "requires": { + "@vue/compiler-core": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "@vue/compiler-sfc": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz", + "integrity": "sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==", + "requires": { + "@babel/parser": "^7.16.4", + "@vue/compiler-core": "3.2.29", + "@vue/compiler-dom": "3.2.29", + "@vue/compiler-ssr": "3.2.29", + "@vue/reactivity-transform": "3.2.29", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.25.7", + "postcss": "^8.1.10", + "source-map": "^0.6.1" + } + }, + "@vue/compiler-ssr": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz", + "integrity": "sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==", + "requires": { + "@vue/compiler-dom": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "@vue/devtools-api": { + "version": "6.0.0-beta.21.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.0.0-beta.21.1.tgz", + "integrity": "sha512-FqC4s3pm35qGVeXRGOjTsRzlkJjrBLriDS9YXbflHLsfA9FrcKzIyWnLXoNm+/7930E8rRakXuAc2QkC50swAw==" + }, + "@vue/reactivity": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.29.tgz", + "integrity": "sha512-Ryhb6Gy62YolKXH1gv42pEqwx7zs3n8gacRVZICSgjQz8Qr8QeCcFygBKYfJm3o1SccR7U+bVBQDWZGOyG1k4g==", + "requires": { + "@vue/shared": "3.2.29" + } + }, + "@vue/reactivity-transform": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz", + "integrity": "sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==", + "requires": { + "@babel/parser": "^7.16.4", + "@vue/compiler-core": "3.2.29", + "@vue/shared": "3.2.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.25.7" + } + }, + "@vue/runtime-core": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.29.tgz", + "integrity": "sha512-VMvQuLdzoTGmCwIKTKVwKmIL0qcODIqe74JtK1pVr5lnaE0l25hopodmPag3RcnIcIXe+Ye3B2olRCn7fTCgig==", + "requires": { + "@vue/reactivity": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "@vue/runtime-dom": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.29.tgz", + "integrity": "sha512-YJgLQLwr+SQyORzTsBQLL5TT/5UiV83tEotqjL7F9aFDIQdFBTCwpkCFvX9jqwHoyi9sJqM9XtTrMcc8z/OjPA==", + "requires": { + "@vue/runtime-core": "3.2.29", + "@vue/shared": "3.2.29", + "csstype": "^2.6.8" + } + }, + "@vue/server-renderer": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.29.tgz", + "integrity": "sha512-lpiYx7ciV7rWfJ0tPkoSOlLmwqBZ9FTmQm33S+T4g0j1fO/LmhJ9b9Ctl1o5xvIFVDk9QkSUWANZn7H2pXuxVw==", + "requires": { + "@vue/compiler-ssr": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "@vue/shared": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.29.tgz", + "integrity": "sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==" + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, + "requires": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", + "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==", + "dev": true + }, + "autoprefixer": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.2.tgz", + "integrity": "sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ==", + "dev": true, + "requires": { + "browserslist": "^4.19.1", + "caniuse-lite": "^1.0.30001297", + "fraction.js": "^4.1.2", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", + "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001286", + "electron-to-chromium": "^1.4.17", + "escalade": "^3.1.1", + "node-releases": "^2.0.1", + "picocolors": "^1.0.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001304", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001304.tgz", + "integrity": "sha512-bdsfZd6K6ap87AGqSHJP/s1V+U6Z5lyrcbBu3ovbCCf8cSYpwTtGrCBObMpJqwxfTbLW6YTIdbb1jEeTelcpYQ==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "csstype": { + "version": "2.6.19", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", + "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==" + }, + "debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, + "detective": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", + "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, + "requires": { + "acorn-node": "^1.6.1", + "defined": "^1.0.0", + "minimist": "^1.1.1" + } + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.57", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.57.tgz", + "integrity": "sha512-FNC+P5K1n6pF+M0zIK+gFCoXcJhhzDViL3DRIGy2Fv5PohuSES1JHR7T+GlwxSxlzx4yYbsuzCZvHxcBSRCIOw==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "esbuild": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz", + "integrity": "sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==", + "dev": true, + "requires": { + "esbuild-android-arm64": "0.13.15", + "esbuild-darwin-64": "0.13.15", + "esbuild-darwin-arm64": "0.13.15", + "esbuild-freebsd-64": "0.13.15", + "esbuild-freebsd-arm64": "0.13.15", + "esbuild-linux-32": "0.13.15", + "esbuild-linux-64": "0.13.15", + "esbuild-linux-arm": "0.13.15", + "esbuild-linux-arm64": "0.13.15", + "esbuild-linux-mips64le": "0.13.15", + "esbuild-linux-ppc64le": "0.13.15", + "esbuild-netbsd-64": "0.13.15", + "esbuild-openbsd-64": "0.13.15", + "esbuild-sunos-64": "0.13.15", + "esbuild-windows-32": "0.13.15", + "esbuild-windows-64": "0.13.15", + "esbuild-windows-arm64": "0.13.15" + } + }, + "esbuild-android-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz", + "integrity": "sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==", + "dev": true, + "optional": true + }, + "esbuild-darwin-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz", + "integrity": "sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==", + "dev": true, + "optional": true + }, + "esbuild-darwin-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz", + "integrity": "sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz", + "integrity": "sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz", + "integrity": "sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz", + "integrity": "sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==", + "dev": true, + "optional": true + }, + "esbuild-linux-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz", + "integrity": "sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz", + "integrity": "sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz", + "integrity": "sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==", + "dev": true, + "optional": true + }, + "esbuild-linux-mips64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz", + "integrity": "sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==", + "dev": true, + "optional": true + }, + "esbuild-linux-ppc64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz", + "integrity": "sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==", + "dev": true, + "optional": true + }, + "esbuild-netbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz", + "integrity": "sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==", + "dev": true, + "optional": true + }, + "esbuild-openbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz", + "integrity": "sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==", + "dev": true, + "optional": true + }, + "esbuild-sunos-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz", + "integrity": "sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==", + "dev": true, + "optional": true + }, + "esbuild-windows-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz", + "integrity": "sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==", + "dev": true, + "optional": true + }, + "esbuild-windows-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz", + "integrity": "sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==", + "dev": true, + "optional": true + }, + "esbuild-windows-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz", + "integrity": "sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==", + "dev": true, + "optional": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "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==" + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fraction.js": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz", + "integrity": "sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "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 + }, + "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 + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "lilconfig": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", + "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "nanoid": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", + "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==" + }, + "node-releases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", + "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "postcss": { + "version": "8.4.5", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", + "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", + "requires": { + "nanoid": "^3.1.30", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.1" + } + }, + "postcss-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", + "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "dev": true, + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-load-config": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.1.tgz", + "integrity": "sha512-c/9XYboIbSEUZpiD1UQD0IKiUe8n9WHYV7YFe7X7J+ZwCsEKkUJSFWjS9hBU1RR9THR7jMXst8sxiqP0jjo2mg==", + "dev": true, + "requires": { + "lilconfig": "^2.0.4", + "yaml": "^1.10.2" + } + }, + "postcss-nested": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", + "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.6" + } + }, + "postcss-selector-parser": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", + "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "prettier": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", + "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rollup": { + "version": "2.66.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.66.1.tgz", + "integrity": "sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tailwindcss": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.18.tgz", + "integrity": "sha512-ihPTpEyA5ANgZbwKlgrbfnzOp9R5vDHFWmqxB1PT8NwOGCOFVVMl+Ps1cQQ369acaqqf1BEF77roCwK0lvNmTw==", + "dev": true, + "requires": { + "arg": "^5.0.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "cosmiconfig": "^7.0.1", + "detective": "^5.2.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "normalize-path": "^3.0.0", + "object-hash": "^2.2.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.0", + "postcss-nested": "5.0.6", + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.21.0" + } + }, + "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, + "requires": { + "is-number": "^7.0.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "vite": { + "version": "2.7.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.7.13.tgz", + "integrity": "sha512-Mq8et7f3aK0SgSxjDNfOAimZGW9XryfHRa/uV0jseQSilg+KhYDSoNb9h1rknOy6SuMkvNDLKCYAYYUMCE+IgQ==", + "dev": true, + "requires": { + "esbuild": "^0.13.12", + "fsevents": "~2.3.2", + "postcss": "^8.4.5", + "resolve": "^1.20.0", + "rollup": "^2.59.0" + } + }, + "vue": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.29.tgz", + "integrity": "sha512-cFIwr7LkbtCRanjNvh6r7wp2yUxfxeM2yPpDQpAfaaLIGZSrUmLbNiSze9nhBJt5MrZ68Iqt0O5scwAMEVxF+Q==", + "requires": { + "@vue/compiler-dom": "3.2.29", + "@vue/compiler-sfc": "3.2.29", + "@vue/runtime-dom": "3.2.29", + "@vue/server-renderer": "3.2.29", + "@vue/shared": "3.2.29" + } + }, + "vue-router": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.12.tgz", + "integrity": "sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==", + "requires": { + "@vue/devtools-api": "^6.0.0-beta.18" + } + }, + "vue3-popper": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/vue3-popper/-/vue3-popper-1.4.1.tgz", + "integrity": "sha512-pmct5vumtvbK8MmUs4oFY+3Al1glU34QXWcIPK4WJhRo/Kp85kxD0j70cNofNBqHYwhY5D7xJ6Yhkwf/5x9w7Q==", + "requires": { + "@popperjs/core": "^2.9.2", + "debounce": "^1.2.1" + } + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..412ac7e --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,22 @@ +{ + "name": "cloud-tasks-dashboard", + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.2.25", + "vue-router": "^4.0.12", + "vue3-popper": "^1.4.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^2.0.0", + "autoprefixer": "^10.4.2", + "postcss": "^8.4.5", + "prettier": "2.5.1", + "tailwindcss": "^3.0.18", + "vite": "^2.7.2" + } +} diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/dashboard/public/crossword.png b/dashboard/public/crossword.png new file mode 100644 index 0000000000000000000000000000000000000000..2f9f1ad08473dcf728044895fd7809749e38eb9e GIT binary patch literal 43694 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 Bg`WTb literal 0 HcmV?d00001 diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/dashboard/public/pw_maze_white.png b/dashboard/public/pw_maze_white.png new file mode 100644 index 0000000000000000000000000000000000000000..66464831c0c4389471dfb0ba94981005f77d12a6 GIT binary patch literal 600 zcmV-e0;m0nP)gws~>GJaO^z`)g_4W4l_Q@I-8vpR-ww`TUoSA}Vkbc?jsbu<&5t1W$|wV}QxLbi{VYj4Y( z`Wq08&8%9Lq<*SnC&26reul>-j;)mzOK*usBd~z4+s{{H3 zD>h_8R#wH+sWAAKse2^!mr1pY=7g9QZ-Rhz*OSi(^SM5HLr070t0mQ;oNx2Jiqsml z|Cf-?aF&&SgS_)psZ(#@&N&dUXXJ9$VWGMczStPj!hpyHPDA< zl*~{c(JqZ;n=T7GWgTAh1l@1gYVX@0`RFl`?7lTBs@~GXaAZeLm&shrznR>m^kjxt7JFuzI^0K7(u6l$&=SMg64tmFQP(razaoluGO^R+;~))Jg0fxzQ-%K4f?}b` z?hV(Yr_ib$+*z^5!s##cv)T(StdAMgjy8k%GxG53^2yf<;{FDUu5LEynSYw#AlO}f mzkdv|erYfIv3Tu9Ab$btOBd6f7(Ds_0000 +
+ +
+ +
+
+ + + + diff --git a/dashboard/src/api.js b/dashboard/src/api.js new file mode 100644 index 0000000..cbed527 --- /dev/null +++ b/dashboard/src/api.js @@ -0,0 +1,46 @@ +import { onUnmounted, watch } from 'vue' +import { onBeforeRouteUpdate } from 'vue-router' + +export async function fetchTasks(into, query = {}) { + const f = async function (into) { + const url = new URL(window.location.href) + const queryParams = new URLSearchParams(url.search) + + for (const [name, value] of Object.entries(query)) { + queryParams.append(name, value) + } + + fetch( + `http://localhost:8000/cloud-tasks-api/tasks?${queryParams.toString()}` + ) + .then((response) => response.json()) + .then((response) => { + into.value = response + }) + } + + f(into) + let interval = setInterval(() => f(into), 3000) + let visibilityChangeListener = null + + // immediately re-fetch results if results have been filtered. + onBeforeRouteUpdate(function () { + setTimeout(() => f(into)) + }) + + const onVisibilityChange = function () { + if (document.visibilityState === 'visible') { + f(into) + clearInterval(interval) + interval = setInterval(() => f(into), 3000) + } else if (document.visibilityState === 'hidden') { + clearInterval(interval) + } + } + document.addEventListener('visibilitychange', onVisibilityChange) + + onUnmounted(() => { + clearInterval(interval) + document.removeEventListener('visibilitychange', onVisibilityChange) + }) +} diff --git a/dashboard/src/assets/logo.png b/dashboard/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f3d2503fc2a44b5053b0837ebea6e87a2d339a43 GIT binary patch literal 6849 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 +import { ref } from 'vue' + +const dashboard = ref({ + recent: { + this_minute: '...', + this_hour: '...', + today: '...', + }, + failed: { + this_minute: '...', + this_hour: '...', + today: '...', + }, +}) + +const tsLoaded = Math.floor(Date.now() / 1000) + +fetch('http://github.com/service/http://localhost:8000/cloud-tasks-api/dashboard') + .then((response) => response.json()) + .then((response) => (dashboard.value = response)) + + + diff --git a/dashboard/src/components/Failed.vue b/dashboard/src/components/Failed.vue new file mode 100644 index 0000000..e9ef924 --- /dev/null +++ b/dashboard/src/components/Failed.vue @@ -0,0 +1,23 @@ + + + diff --git a/dashboard/src/components/FilterCard.vue b/dashboard/src/components/FilterCard.vue new file mode 100644 index 0000000..c9364b6 --- /dev/null +++ b/dashboard/src/components/FilterCard.vue @@ -0,0 +1,80 @@ + + + diff --git a/dashboard/src/components/Icon.vue b/dashboard/src/components/Icon.vue new file mode 100644 index 0000000..a29e556 --- /dev/null +++ b/dashboard/src/components/Icon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/dashboard/src/components/Menu.vue b/dashboard/src/components/Menu.vue new file mode 100644 index 0000000..3458e78 --- /dev/null +++ b/dashboard/src/components/Menu.vue @@ -0,0 +1,31 @@ + diff --git a/dashboard/src/components/Overview.vue b/dashboard/src/components/Overview.vue new file mode 100644 index 0000000..8679fd0 --- /dev/null +++ b/dashboard/src/components/Overview.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/dashboard/src/components/Queued.vue b/dashboard/src/components/Queued.vue new file mode 100644 index 0000000..5a021a3 --- /dev/null +++ b/dashboard/src/components/Queued.vue @@ -0,0 +1,23 @@ + + + diff --git a/dashboard/src/components/Recent.vue b/dashboard/src/components/Recent.vue new file mode 100644 index 0000000..259c3f1 --- /dev/null +++ b/dashboard/src/components/Recent.vue @@ -0,0 +1,23 @@ + + + diff --git a/dashboard/src/components/Spinner.vue b/dashboard/src/components/Spinner.vue new file mode 100644 index 0000000..c2a0d55 --- /dev/null +++ b/dashboard/src/components/Spinner.vue @@ -0,0 +1,22 @@ + diff --git a/dashboard/src/components/Status.vue b/dashboard/src/components/Status.vue new file mode 100644 index 0000000..35f4de8 --- /dev/null +++ b/dashboard/src/components/Status.vue @@ -0,0 +1,37 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/Task.vue b/dashboard/src/components/Task.vue new file mode 100644 index 0000000..e1bf144 --- /dev/null +++ b/dashboard/src/components/Task.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/dashboard/src/components/TaskRowSpinner.vue b/dashboard/src/components/TaskRowSpinner.vue new file mode 100644 index 0000000..a877678 --- /dev/null +++ b/dashboard/src/components/TaskRowSpinner.vue @@ -0,0 +1,28 @@ + diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/dashboard/src/main.js b/dashboard/src/main.js new file mode 100644 index 0000000..63cba74 --- /dev/null +++ b/dashboard/src/main.js @@ -0,0 +1,88 @@ +import { createApp } from 'vue/dist/vue.esm-bundler' +import App from './App.vue' +import './index.css' +import { createRouter, createWebHistory } from 'vue-router' +import Popper from 'vue3-popper' + +// 1. Define route components. +// These can be imported from other files +import Dashboard from './components/Dashboard.vue' +import Recent from './components/Recent.vue' +import Queued from './components/Queued.vue' +import Failed from './components/Failed.vue' +import Task from './components/Task.vue' + +// 2. Define some routes +// Each route should map to a component. +// We'll talk about nested routes later. +const routes = [ + { + name: 'home', + path: '/', + component: Dashboard, + }, + { + name: 'recent', + path: '/recent', + component: Recent, + meta: { + route: 'recent', + }, + }, + { + name: 'recent-task', + path: '/recent/:uuid', + component: Task, + meta: { + route: 'recent', + }, + }, + { + name: 'queued', + path: '/queued', + component: Queued, + meta: { + route: 'queued', + }, + }, + { + name: 'queued-task', + path: '/queued/:uuid', + component: Task, + meta: { + route: 'queued', + }, + }, + { + name: 'failed', + path: '/failed', + component: Failed, + meta: { + route: 'failed', + }, + }, + { + name: 'failed-task', + path: '/failed/:uuid', + component: Task, + meta: { + route: 'failed', + }, + }, +] + +// 3. Create the router instance and pass the `routes` option +// You can pass in additional options here, but let's +// keep it simple for now. +let routerBasePath = null +if ('CloudTasks' in window) { + routerBasePath = `/${window.CloudTasks.path}` +} + +const router = createRouter({ + // 4. Provide the history implementation to use. We are using the hash history for simplicity here. + history: createWebHistory(routerBasePath), + routes, // short for `routes: routes`, +}) + +createApp(App).use(router).component('Popper', Popper).mount('#app') diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js new file mode 100644 index 0000000..c3d7982 --- /dev/null +++ b/dashboard/tailwind.config.js @@ -0,0 +1,10 @@ +module.exports = { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js new file mode 100644 index 0000000..3cbd7b6 --- /dev/null +++ b/dashboard/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + build: { + manifest: true, + target: 'es2015', + }, +}) diff --git a/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php b/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php new file mode 100644 index 0000000..2455a5a --- /dev/null +++ b/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php @@ -0,0 +1,41 @@ +increments('id'); + $table->string('queue'); + $table->string('task_uuid'); + $table->string('name'); + $table->string('status'); + $table->text('metadata'); + $table->text('payload'); + $table->timestamps(); + + $table->index('task_uuid'); + $table->index('queue'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('stackkit_cloud_tasks'); + } +} diff --git a/src/Authenticate.php b/src/Authenticate.php new file mode 100644 index 0000000..75ad81a --- /dev/null +++ b/src/Authenticate.php @@ -0,0 +1,11 @@ +environment('local'); + })($request); + } +} \ No newline at end of file diff --git a/src/CloudTasksApiController.php b/src/CloudTasksApiController.php new file mode 100644 index 0000000..5ea44a4 --- /dev/null +++ b/src/CloudTasksApiController.php @@ -0,0 +1,163 @@ + [ + 'this_minute' => 'DATE_FORMAT(created_at, \'%H:%i\')', + 'this_hour' => 'DATE_FORMAT(created_at, \'%H\')', + ], + 'pgsql' => [ + 'this_minute' => 'TO_CHAR(created_at :: TIME, \'HH24:MI\')', + 'this_hour' => 'TO_CHAR(created_at :: TIME, \'HH24\')', + ], + ][$dbDriver]; + + $stats = DB::table((new StackkitCloudTask())->getTable()) + ->where('created_at', '>=', now()->utc()->startOfDay()) + ->select( + [ + DB::raw('COUNT(id) as count'), + DB::raw('CASE WHEN status = \'failed\' THEN 1 ELSE 0 END AS failed'), + DB::raw(' + CASE + WHEN ' . $groupBy['this_minute'] . ' = \'' . now()->utc()->format('H:i') . '\' THEN \'this_minute\' + WHEN ' . $groupBy['this_hour'] . ' = \'' . now()->utc()->format('H') . '\' THEN \'this_hour\' + + ELSE \'today\' + END AS time_preset + ') + ] + ) + ->groupBy( + [ + 'failed', + 'time_preset', + ] + ) + ->get() + ->toArray(); + + $response = [ + 'recent' => [ + 'this_minute' => 0, + 'this_hour' => 0, + 'this_day' => 0, + ], + 'failed' => [ + 'this_minute' => 0, + 'this_hour' => 0, + 'this_day' => 0, + ], + ]; + + foreach ($stats as $row) { + $response['recent']['this_day'] += $row->count; + + if ($row->time_preset === 'this_minute') { + $response['recent']['this_minute'] += $row->count; + $response['recent']['this_hour'] += $row->count; + } + + if ($row->time_preset === 'this_hour') { + $response['recent']['this_hour'] += $row->count; + } + + if ($row->failed === 0) { + continue; + } + + $response['failed']['this_day'] += $row->count; + + if ($row->time_preset === 'this_minute') { + $response['failed']['this_minute'] += $row->count; + $response['failed']['this_hour'] += $row->count; + } + + if ($row->time_preset === 'this_hour') { + $response['failed']['this_hour'] += $row->count; + } + } + + return $response; + } + + public function tasks() + { + Carbon::setTestNowAndTimezone(now()->utc()); + + $tasks = StackkitCloudTask::query() + ->newestFirst() + ->where('created_at', '>=', now()->utc()->startOfDay()) + ->when(request('filter') === 'failed', function (Builder $builder) { + return $builder->where('status', 'failed'); + }) + ->when(request('time'), function (Builder $builder) { + [$hour, $minute] = explode(':', request('time')); + + return $builder + ->where('created_at', '>=', now()->setTime($hour, $minute, 0)) + ->where('created_at', '<=', now()->setTime($hour, $minute, 59)); + }) + ->when(request('hour'), function (Builder $builder, $hour) { + return $builder->where('created_at', '>=', now()->setTime($hour, 0, 0)) + ->where('created_at', '<=', now()->setTime($hour, 59, 59)); + }) + ->when(request('queue'), function (Builder $builder, $queue) { + return $builder->where('queue', $queue); + }) + ->when(request('status'), function (Builder $builder, $status) { + return $builder->where('status', $status); + }) + ->limit(100) + ->get(); + + $maxId = $tasks->max('id'); + + return $tasks->map(function (StackkitCloudTask $task) use ($tasks, $maxId) + { + return [ + 'uuid' => $task->task_uuid, + 'id' => str_pad($task->id, strlen($maxId), 0, STR_PAD_LEFT), + 'name' => $task->name, + 'status' => $task->status, + 'attempts' => $task->getNumberOfAttempts(), + 'created' => $task->created_at->diffForHumans(), + 'queue' => $task->queue, + ]; + }); + } + + public function task($uuid) + { + /** + * @var StackkitCloudTask $task + */ + $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + + return [ + 'id' => $task->id, + 'status' => $task->status, + 'queue' => $task->queue, + 'events' => $task->getEvents(), + 'payload' => $task->getPayloadPretty(), + 'exception' => $task->getMetadata()['exception'] ?? null, + ]; + } +} \ No newline at end of file diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 20530eb..d67ee50 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -73,6 +73,8 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); } + MonitoringService::make()->addToMonitor($queue, $task); + $this->client->createTask($queueName, $task); } diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index cda6513..15574e2 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -5,17 +5,54 @@ use Google\Cloud\Tasks\V2\CloudTasksClient; use Illuminate\Queue\QueueManager; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; class CloudTasksServiceProvider extends LaravelServiceProvider { public function boot(QueueManager $queue, Router $router) { + $this->authorization(); + $this->registerClient(); $this->registerConnector($queue); + $this->registerViews(); + $this->registerAssets(); + $this->registerMigrations(); $this->registerRoutes($router); } + /** + * Configure the Cloud Tasks authorization services. + * + * @return void + */ + protected function authorization() + { + $this->gate(); + + CloudTasks::auth(function ($request) { + return app()->environment('local') || + Gate::check('viewCloudTasks', [$request->user()]); + }); + } + + /** + * Register the Cloud Tasks gate. + * + * This gate determines who can access Cloud Tasks in non-local environments. + * + * @return void + */ + protected function gate() + { + Gate::define('viewCloudTasks', function ($user) { + return in_array($user->email, [ + // + ]); + }); + } + private function registerClient() { $this->app->singleton(CloudTasksClient::class, function () { @@ -30,8 +67,48 @@ private function registerConnector(QueueManager $queue) }); } + private function registerViews() + { + $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); + } + + private function registerAssets() + { + $this->publishes([ + __DIR__ . '/../dashboard/dist' => public_path('vendor/cloud-tasks'), + ], ['cloud-tasks-assets']); + } + + private function registerMigrations() + { + $this->loadMigrationsFrom([ + __DIR__ . '/../migrations', + ]); + } + private function registerRoutes(Router $router) { $router->post('handle-task', [TaskHandler::class, 'handle']); + + $router->middleware(Authenticate::class)->group(function () use ($router) { + $router->get('cloud-tasks/{view?}', function () { + return view('cloud-tasks::layout', [ + 'manifest' => json_decode(file_get_contents(public_path('vendor/cloud-tasks/manifest.json')), true), + 'isDownForMaintenance' => app()->isDownForMaintenance(), + 'cloudTasksScriptVariables' => [ + 'path' => 'cloud-tasks', + ], + ]); + })->where( + 'view', + '(.+)' + )->name( + 'cloud-tasks.index' + ); + + $router->get('cloud-tasks-api/dashboard', [CloudTasksApiController::class, 'dashboard']); + $router->get('cloud-tasks-api/tasks', [CloudTasksApiController::class, 'tasks']); + $router->get('cloud-tasks-api/task/{uuid}', [CloudTasksApiController::class, 'task']); + }); } } diff --git a/src/MonitoringService.php b/src/MonitoringService.php new file mode 100644 index 0000000..92cb8a5 --- /dev/null +++ b/src/MonitoringService.php @@ -0,0 +1,118 @@ +payload = $task->getHttpRequest()->getBody(); + $metadata->addEvent('queued', [ + 'queue' => $queue, + ]); + + DB::table('stackkit_cloud_tasks') + ->insert([ + 'task_uuid' => $this->getTaskUuid($task), + 'name' => $this->getTaskName($task), + 'queue' => $queue, + 'payload' => $task->getHttpRequest()->getBody(), + 'status' => 'queued', + 'metadata' => $metadata->toJson(), + 'created_at' => now()->utc(), + 'updated_at' => now()->utc(), + ]); + } + + public function markAsRunning($uuid) + { + $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + + $task->status = 'running'; + $metadata = $task->getMetadata(); + $events = Arr::get($metadata, 'events', []); + $events[] = [ + 'status' => $task->status, + 'datetime' => now()->utc()->toDateTimeString(), + ]; + $task->setMetadata('events', $events); + + $task->save(); + } + + public function markAsSuccessful($uuid) + { + $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + + $task->status = 'successful'; + $metadata = $task->getMetadata(); + $events = Arr::get($metadata, 'events', []); + $events[] = [ + 'status' => $task->status, + 'datetime' => now()->utc()->toDateTimeString(), + ]; + $task->setMetadata('events', $events); + + $task->save(); + } + + public function markAsError(JobExceptionOccurred $event) + { + $task = StackkitCloudTask::whereTaskUuid($event->job->uuid()) + ->where('status', '!=', 'failed') + ->firstOrFail(); + + $task->status = 'error'; + $metadata = $task->getMetadata(); + $events = Arr::get($metadata, 'events', []); + $events[] = [ + 'status' => $task->status, + 'datetime' => now()->utc()->toDateTimeString(), + ]; + $task->setMetadata('events', $events); + $task->setMetadata('exception', (string) $event->exception); + + $task->save(); + } + + public function markAsFailed(JobFailed $event) + { + $task = StackkitCloudTask::whereTaskUuid($event->job->uuid())->firstOrFail(); + + $task->status = 'failed'; + $metadata = $task->getMetadata(); + $events = Arr::get($metadata, 'events', []); + $events[] = [ + 'status' => $task->status, + 'datetime' => now()->utc()->toDateTimeString(), + ]; + $task->setMetadata('events', $events); + + $task->save(); + } + + private function getTaskName(Task $task) + { + $decode = json_decode($task->getHttpRequest()->getBody(), true); + + return $decode['displayName']; + } + + private function getTaskUuid(Task $task) + { + return json_decode($task->getHttpRequest()->getBody())->uuid; + } +} \ No newline at end of file diff --git a/src/StackkitCloudTask.php b/src/StackkitCloudTask.php new file mode 100644 index 0000000..c0a5bc1 --- /dev/null +++ b/src/StackkitCloudTask.php @@ -0,0 +1,91 @@ +orderByDesc('created_at'); + } + + public function scopeFailed($builder) + { + return $builder->whereStatus('failed'); + } + + public function getMetadata() + { + $value = $this->metadata; + + if (is_null($value)) { + return []; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : []; + } + + return is_array($value) ? $value : []; + } + + /** + * @return int + */ + public function getNumberOfAttempts() + { + $events = Arr::get($this->getMetadata(), 'events', []); + + return count(array_filter($events, function (array $event) { + return in_array( + $event['status'], + ['running'] + ); + })); + } + + public function setMetadata($key, $value) + { + $metadata = $this->getMetadata(); + + Arr::set($metadata, $key, $value); + + $this->metadata = json_encode($metadata); + } + + public function incrementAttempts() + { + // + } + + public function getEvents() + { + Carbon::setTestNowAndTimezone(now()->utc()); + + $events = Arr::get($this->getMetadata(), 'events', []); + + return array_map(function (array $event) { + $event['diff'] = Carbon::parse($event['datetime'])->diffForHumans(); + return $event; + }, $events); + } + + public function getPayloadPretty() + { + $payload = $this->getMetadata()['payload'] ?? '[]'; + + return json_encode( + json_decode($payload), + JSON_PRETTY_PRINT + ); + } +} \ No newline at end of file diff --git a/src/TaskHandler.php b/src/TaskHandler.php index 7b0c42f..0ccadee 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -6,7 +6,10 @@ use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; use Illuminate\Http\Request; +use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Worker; use Illuminate\Queue\WorkerOptions; @@ -127,7 +130,29 @@ private function captureTask() private function listenForEvents() { + app('events')->listen(JobProcessing::class, function (JobProcessing $event) { + MonitoringService::make()->markAsRunning( + $event->job->uuid() + ); + }); + + app('events')->listen(JobProcessed::class, function (JobProcessed $event) { + MonitoringService::make()->markAsSuccessful( + $event->job->uuid() + ); + }); + + app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { + MonitoringService::make()->markAsError( + $event + ); + }); + app('events')->listen(JobFailed::class, function ($event) { + MonitoringService::make()->markAsFailed( + $event + ); + app('queue.failer')->log( $this->config['connection'], $event->job->getQueue(), $event->job->getRawBody(), $event->exception diff --git a/src/TaskMetadata.php b/src/TaskMetadata.php new file mode 100644 index 0000000..8582818 --- /dev/null +++ b/src/TaskMetadata.php @@ -0,0 +1,53 @@ + $status, + 'datetime' => now()->utc()->toDateTimeString(), + ]; + + $this->events[] = array_merge($additional, $event); + } + + public function toArray() + { + return [ + 'events' => $this->events, + 'payload' => $this->payload, + ]; + } + + public function toJson() + { + return json_encode($this->toArray()); + } + + public static function createFromArray(array $data) + { + $metadata = new TaskMetadata(); + + $metadata->events = $data['events']; + $metadata->payload = $data['payload']; + + return $metadata; + } +} \ No newline at end of file diff --git a/views/layout.blade.php b/views/layout.blade.php new file mode 100644 index 0000000..c7f0474 --- /dev/null +++ b/views/layout.blade.php @@ -0,0 +1,28 @@ + + + + + + + + + + Cloud Tasks for Laravel + + + + + @foreach ($manifest['index.html']['css'] as $css) + + @endforeach + + +
+ + + + + + From 7dc6fe00144684387d706f7d6b807b2e95133cb9 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 6 Feb 2022 19:59:59 +0100 Subject: [PATCH 002/258] Remove support for Laravel 5 --- .github/workflows/run-tests.yml | 20 +------------------- README.md | 3 --- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 11c6662..2bb20df 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: php: [8.1, 8.0, 7.4, 7.3, 7.2] - laravel: [8.*, 7.*, 6.*, 5.8.*, 5.7.*, 5.6.*] + laravel: [8.*, 7.*, 6.*] os: [ubuntu-latest] include: - laravel: 8.* @@ -21,27 +21,9 @@ jobs: testbench: 5.* - laravel: 6.* testbench: 4.* - - laravel: 5.8.* - testbench: 3.8.* - - laravel: 5.7.* - testbench: 3.7.* - - laravel: 5.6.* - testbench: 3.6.* exclude: - laravel: 8.* php: 7.2 - - laravel: 5.8.* - php: 8.0 - - laravel: 5.7.* - php: 8.0 - - laravel: 5.6.* - php: 8.0 - - laravel: 5.6.* - php: 8.1 - - laravel: 5.7.* - php: 8.1 - - laravel: 5.8.* - php: 8.1 - laravel: 6.* php: 8.1 - laravel: 7.* diff --git a/README.md b/README.md index 2fbdf30..42e4a26 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,6 @@ Please check the table below for supported Laravel and PHP versions: |Laravel Version| PHP Version | |---|---| -| 5.6 | 7.2 or 7.3 or 7.4 -| 5.7 | 7.2 or 7.3 or 7.4 -| 5.8 | 7.2 or 7.3 or 7.4 | 6.x | 7.2 or 7.3 or 7.4 or 8.0 | 7.x | 7.2 or 7.3 or 7.4 or 8.0 | 8.x | 7.3 or 7.4 or 8.0 or 8.1 From 86ad67448342da958c08b3fb3216558fc7e6b9bc Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Wed, 9 Feb 2022 20:13:33 +0100 Subject: [PATCH 003/258] wip --- README.md | 12 +++ docker-compose.yml | 21 ++++ phpunit.xml | 21 ++-- setup-test-env.php | 73 +++++++++++++ src/CloudTasksServiceProvider.php | 13 ++- src/OpenIdVerificator.php | 37 ++++--- src/TaskHandler.php | 16 ++- tests/.gitignore | 1 + tests/GooglePublicKeyTest.php | 16 +-- tests/QueueTest.php | 52 +++++---- tests/Support/SimpleJob.php | 2 +- tests/TaskHandlerTest.php | 174 +++--------------------------- tests/TestCase.php | 118 ++++++++++++++++---- 13 files changed, 311 insertions(+), 245 deletions(-) create mode 100644 docker-compose.yml create mode 100644 setup-test-env.php create mode 100644 tests/.gitignore diff --git a/README.md b/README.md index 42e4a26..aa4828b 100644 --- a/README.md +++ b/README.md @@ -194,3 +194,15 @@ This package verifies that the token is digitally signed by Google. Only Google More information about OpenID Connect: https://developers.google.com/identity/protocols/oauth2/openid-connect + +# Running tests + +The test suite uses a emulated version of Google Tasks, thanks to aertje/cloud-tasks-emulator. + +To start running the tests locally, first start all Docker services: + +``` +docker compose up -d +``` + +This will start the emulator, MySQL and Postgres. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..daab881 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + gcloud-tasks-emulator: + image: ghcr.io/aertje/cloud-tasks-emulator:latest + command: -host 0.0.0.0 -openid-issuer http://localhost:8980 -port 8123 -queue "projects/my-test-project/locations/europe-west6/queues/barbequeue" + ports: + - "${TASKS_PORT:-8123}:8123" + - 8980:8980 + mysql: + image: mysql:8 + ports: + - 3306:3306 + environment: + MYSQL_ROOT_PASSWORD: 'my-secret-pw' + MYSQL_DATABASE: 'cloudtasks' + postgres: + image: postgres:14 + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: 'my-secret-pw' + POSTGRES_DB: 'cloudtasks' \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 994539f..dd7aa5b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,10 +8,12 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false"> - - - ./tests/ + + tests/ConfigTest.php + tests/GooglePublicKeyTest.php + tests/QueueTest.php + tests/TaskHandlerTest.php @@ -23,12 +25,11 @@ - + + + + + + - - - - src/ - - diff --git a/setup-test-env.php b/setup-test-env.php new file mode 100644 index 0000000..3bf18f5 --- /dev/null +++ b/setup-test-env.php @@ -0,0 +1,73 @@ + 'path', + 'url' => '../../', + ], + ]; + } + + $encoded = json_encode($toArray, JSON_PRETTY_PRINT); + + file_put_contents('./tests/laravel/composer.json', $encoded); + + $envs = [ + 'APP_ENV=local' => 'APP_ENV=testing', + 'DB_DATABASE=laravel' => 'DB_DATABASE=cloudtasks', + "DB_PASSWORD=\n" => "DB_PASSWORD=my-secret-pw\n", + 'QUEUE_CONNECTION=sync' => 'QUEUE_CONNECTION=cloudtasks', + ]; + + file_put_contents( + './tests/laravel/.env', + str_replace( + array_keys($envs), + array_values($envs), + file_get_contents('./tests/laravel/.env') + ) + ); + + // Prepare the config/queue.php file. + function env() { + // + } + $queue = include('./tests/laravel/config/queue.php'); + + if (!isset($queue['connections']['cloudtasks'])) { + $queue['default'] = 'cloudtasks'; + $queue['connections']['cloudtasks'] = [ + 'driver' => 'cloudtasks', + 'project' => 'my-test-project', + 'queue' => 'barbequeue', + 'location' => 'europe-west6', + 'handler' => 'http://docker.for.mac.localhost:8080/handle-task', + 'service_account_email' => 'info@stackkit.io', + ]; + file_put_contents('./tests/laravel/config/queue.php', 'app->singleton(CloudTasksClient::class, function () { - return new CloudTasksClient(); + return new CloudTasksClient([ + 'apiEndpoint' => 'localhost:8123', + 'transport' => 'grpc', + 'transportConfig' => [ + 'grpc' => [ + 'stubOpts' => [ + 'credentials' => ChannelCredentials::createInsecure() + ] + ] + ] + ]); }); } diff --git a/src/OpenIdVerificator.php b/src/OpenIdVerificator.php index 0cbcfe8..263e3b0 100644 --- a/src/OpenIdVerificator.php +++ b/src/OpenIdVerificator.php @@ -14,7 +14,8 @@ class OpenIdVerificator { private const V3_CERTS = 'GOOGLE_V3_CERTS'; - private const URL_OPENID_CONFIG = 'https://accounts.google.com/.well-known/openid-configuration'; + // private const URL_OPENID_CONFIG = 'https://accounts.google.com/.well-known/openid-configuration'; + private const URL_OPENID_CONFIG = 'http://localhost:8980/.well-known/openid-configuration'; private const URL_TOKEN_INFO = 'https://www.googleapis.com/oauth2/v3/tokeninfo'; private $guzzle; @@ -29,26 +30,29 @@ public function __construct(Client $guzzle, RSA $rsa, JWT $jwt) $this->jwt = $jwt; } - public function decodeOpenIdToken($openIdToken, $kid, $cache = true) + public function decodeOpenIdToken($openIdToken, $cache = false) { if (!$cache) { $this->forgetFromCache(); } - $publicKey = $this->getPublicKey($kid); + $publicKeys = $this->getPublicKeys(); + $exception = null; - try { - return $this->jwt->decode($openIdToken, $publicKey, ['RS256']); - } catch (SignatureInvalidException $e) { - if (!$cache) { - throw $e; + foreach ($publicKeys as $publicKey) { + try { + return $this->jwt->decode($openIdToken, $publicKey, ['RS256']); + } catch (SignatureInvalidException $e) { + $exception = $e; } + } - return $this->decodeOpenIdToken($openIdToken, $kid, false); + if ($exception instanceof SignatureInvalidException) { + throw $exception; } } - public function getPublicKey($kid = null) + public function getPublicKeys() { $v3Certs = Cache::get(self::V3_CERTS); @@ -57,9 +61,13 @@ public function getPublicKey($kid = null) Cache::put(self::V3_CERTS, $v3Certs, Carbon::now()->addSeconds($this->maxAge[self::URL_OPENID_CONFIG])); } - $cert = $kid ? collect($v3Certs)->firstWhere('kid', '=', $kid) : $v3Certs[0]; + $publicKeys = []; + + foreach ($v3Certs as $v3Cert) { + $publicKeys[] = $this->extractPublicKeyFromCertificate($v3Cert); + } - return $this->extractPublicKeyFromCertificate($cert); + return $publicKeys; } private function getFreshCertificates() @@ -79,11 +87,6 @@ private function extractPublicKeyFromCertificate($certificate) return $this->rsa->getPublicKey(); } - public function getKidFromOpenIdToken($openIdToken) - { - return $this->callApiAndReturnValue(self::URL_TOKEN_INFO . '?id_token=' . $openIdToken, 'kid'); - } - private function callApiAndReturnValue($url, $value) { $response = $this->guzzle->get($url); diff --git a/src/TaskHandler.php b/src/TaskHandler.php index 0ccadee..0ab22ed 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -80,9 +80,8 @@ public function authorizeRequest() } $openIdToken = $this->request->bearerToken(); - $kid = $this->publicKey->getKidFromOpenIdToken($openIdToken); - $decodedToken = $this->publicKey->decodeOpenIdToken($openIdToken, $kid); + $decodedToken = $this->publicKey->decodeOpenIdToken($openIdToken); $this->validateToken($decodedToken); } @@ -95,7 +94,11 @@ public function authorizeRequest() */ protected function validateToken($openIdToken) { - if (!in_array($openIdToken->iss, ['https://accounts.google.com/', 'accounts.google.com'])) { + $allowedIssuers = app()->runningUnitTests() + ? ['http://localhost:8980/'] + : ['https://accounts.google.com/', 'accounts.google.com']; + + if (!in_array($openIdToken->iss, $allowedIssuers)) { throw new CloudTasksException('The given OpenID token is not valid'); } @@ -195,6 +198,13 @@ private function loadQueueRetryConfig() ); $this->retryConfig = $this->client->getQueue($queueName)->getRetryConfig(); + + // @todo: Need to figure out how to configure this in the emulator itself instead of doing it here. + if (app()->runningUnitTests()) { + $this->retryConfig->setMaxAttempts(3); + $this->retryConfig->setMinBackoff(new \Google\Protobuf\Duration(['seconds' => 0])); + $this->retryConfig->setMaxBackoff(new \Google\Protobuf\Duration(['seconds' => 0])); + } } private function getRetryUntilTimestamp(CloudTasksJob $job) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d6c90e9 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +laravel/ diff --git a/tests/GooglePublicKeyTest.php b/tests/GooglePublicKeyTest.php index f8e6b4a..33be064 100644 --- a/tests/GooglePublicKeyTest.php +++ b/tests/GooglePublicKeyTest.php @@ -37,7 +37,7 @@ protected function setUp(): void /** @test */ public function it_fetches_the_gcloud_public_key() { - $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $this->publicKey->getPublicKey()); + $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $this->publicKey->getPublicKeys()); } /** @test */ @@ -45,7 +45,7 @@ public function it_caches_the_gcloud_public_key() { $this->assertFalse($this->publicKey->isCached()); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); $this->assertTrue($this->publicKey->isCached()); } @@ -55,12 +55,12 @@ public function it_will_return_the_cached_gcloud_public_key() { Event::fake(); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); Event::assertDispatched(CacheMissed::class); Event::assertDispatched(KeyWritten::class); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); Event::assertDispatched(CacheHit::class); @@ -72,15 +72,15 @@ public function public_key_is_cached_according_to_cache_control_headers() { Event::fake(); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); Carbon::setTestNow(Carbon::now()->addSeconds(3600)); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); Carbon::setTestNow(Carbon::now()->addSeconds(5)); - $this->publicKey->getPublicKey(); + $this->publicKey->getPublicKeys(); Event::assertDispatched(CacheMissed::class, 2); Event::assertDispatched(KeyWritten::class, 2); diff --git a/tests/QueueTest.php b/tests/QueueTest.php index f8ec4b0..57ad95c 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -8,29 +8,25 @@ use Google\Cloud\Tasks\V2\HttpRequest; use Google\Cloud\Tasks\V2\Task; use Google\Protobuf\Timestamp; +use Grpc\ChannelCredentials; use Mockery; use Tests\Support\SimpleJob; class QueueTest extends TestCase { - private $client; + /** + * @var HttpRequest $http + */ private $http; - private $task; protected function setUp(): void { parent::setUp(); - $this->client = Mockery::mock(CloudTasksClient::class)->makePartial(); - $this->http = Mockery::mock(new HttpRequest)->makePartial(); - $this->task = Mockery::mock(new Task); - - $this->app->instance(CloudTasksClient::class, $this->client); - $this->app->instance(HttpRequest::class, $this->http); - $this->app->instance(Task::class, $this->task); - - // ensure we don't actually call the Google API - $this->client->shouldReceive('createTask')->andReturnNull(); + $this->http = $this->instance( + HttpRequest::class, + Mockery::mock(new HttpRequest)->makePartial() + ); } /** @test */ @@ -40,7 +36,7 @@ public function a_http_request_with_the_handler_url_is_made() $this->http ->shouldHaveReceived('setUrl') - ->with('https://localhost/my-handler') + ->with('http://docker.for.mac.localhost:8080/handle-task') ->once(); } @@ -81,24 +77,23 @@ public function it_posts_the_serialized_job_payload_to_the_handler() })); } - /** @test */ - public function it_creates_a_task_containing_the_http_request() - { - $this->task->shouldReceive('setHttpRequest')->once()->with($this->http); - - SimpleJob::dispatch(); - } - /** @test */ public function it_will_set_the_scheduled_time_when_dispatching_later() { + $task = $this->instance( + Task::class, + Mockery::mock(new Task)->makePartial() + ); + $inFiveMinutes = Carbon::now()->addMinutes(5); SimpleJob::dispatch()->delay($inFiveMinutes); - $this->task->shouldHaveReceived('setScheduleTime')->once()->with(Mockery::on(function (Timestamp $timestamp) use ($inFiveMinutes) { - return $timestamp->getSeconds() === $inFiveMinutes->timestamp; - })); + $task->shouldHaveReceived('setScheduleTime') + ->once() + ->with(Mockery::on(function (Timestamp $timestamp) use ($inFiveMinutes) { + return $timestamp->getSeconds() === $inFiveMinutes->timestamp; + })); } /** @test */ @@ -109,7 +104,7 @@ public function it_posts_the_task_the_correct_queue() $this->client ->shouldHaveReceived('createTask') ->withArgs(function ($queueName) { - return $queueName === 'projects/test-project/locations/europe-west6/queues/test-queue'; + return $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; }); } @@ -120,8 +115,11 @@ public function it_posts_the_correct_task_the_queue() $this->client ->shouldHaveReceived('createTask') - ->withArgs(function ($queueName, $task) { - return $task === $this->task; + ->withArgs(function ($queueName, Task $task) { + return strpos( + $task->getHttpRequest()->getBody(), + 'SimpleJob' + ) !== false; }); } } diff --git a/tests/Support/SimpleJob.php b/tests/Support/SimpleJob.php index 4a2c8cf..34e1912 100644 --- a/tests/Support/SimpleJob.php +++ b/tests/Support/SimpleJob.php @@ -30,6 +30,6 @@ public function __construct() */ public function handle() { - Mail::to('johndoe@example.com')->send(new TestMailable()); + logger('SimpleJob:success'); } } diff --git a/tests/TaskHandlerTest.php b/tests/TaskHandlerTest.php index c0c6262..9679433 100644 --- a/tests/TaskHandlerTest.php +++ b/tests/TaskHandlerTest.php @@ -18,185 +18,43 @@ use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksException; use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; use Stackkit\LaravelGoogleCloudTasksQueue\TaskHandler; +use Tests\Support\FailingJob; +use Tests\Support\SimpleJob; use Tests\Support\TestMailable; class TaskHandlerTest extends TestCase { - /** - * @var TaskHandler - */ - private $handler; - - private $jwt; - - private $request; - - private $cloudTasksClient; - protected function setUp(): void { parent::setUp(); - $this->request = request(); - - // We don't have a valid token to test with, so for now act as if its always valid - $this->app->instance(JWT::class, ($this->jwt = Mockery::mock(new JWT())->byDefault()->makePartial())); - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => 'https://localhost/my-handler', - 'exp' => time() + 10 - ])->byDefault(); - - // Ensure we don't fetch the Google public key each test... - $googlePublicKey = Mockery::mock(app(OpenIdVerificator::class)); - $googlePublicKey->shouldReceive('getPublicKey')->andReturnNull(); - $googlePublicKey->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - - $cloudTasksClient = Mockery::mock(new CloudTasksClient())->byDefault(); - $this->cloudTasksClient = $cloudTasksClient; - - // Ensure we don't fetch the Queue name and attempts each test... - $cloudTasksClient->shouldReceive('queueName')->andReturn('my-queue'); - $cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return 3; - } - - public function hasMaxRetryDuration() { - return true; - } - - public function getMaxRetryDuration() { - return new class { - public function getSeconds() { - return 30; - } - }; - } - }; - } - }); - $cloudTasksClient->shouldReceive('taskName')->andReturn('FakeTaskName'); - $cloudTasksClient->shouldReceive('getTask')->byDefault()->andReturn(new class { - public function getFirstAttempt() { - return null; - } - }); - - $cloudTasksClient->shouldReceive('deleteTask')->andReturnNull(); - - $this->handler = new TaskHandler( - $cloudTasksClient, - request(), - $googlePublicKey - ); - - $this->request->headers->add(['Authorization' => 'Bearer 123']); - } - - /** @test */ - public function it_needs_an_authorization_header() - { - $this->request->headers->remove('Authorization'); - - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('Missing [Authorization] header'); - - $this->handler->handle($this->simpleJob()); - } - - /** @test */ - public function it_will_validate_the_token_iss() - { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'test', - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); - $this->handler->handle($this->simpleJob()); - } - - /** @test */ - public function it_will_validate_the_token_handler() - { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => '__incorrect_aud__' - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); - $this->handler->handle($this->simpleJob()); - } - - /** @test */ - public function it_will_validate_the_token_expiration() - { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => 'https://localhost/my-handler', - 'exp' => time() - 1 - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token has expired'); - $this->handler->handle($this->simpleJob()); - } - - /** @test */ - public function in_case_of_signature_verification_failure_it_will_retry() - { - Event::fake(); - - $this->jwt->shouldReceive('decode')->andThrow(SignatureInvalidException::class); - - $this->expectException(SignatureInvalidException::class); - - $this->handler->handle($this->simpleJob()); - - Event::assertDispatched(CacheHit::class); - Event::assertDispatched(KeyWritten::class); + $this->clearLaravelStorageFile(); + $this->clearTables(); } /** @test */ public function it_runs_the_incoming_job() { - Mail::fake(); - - request()->headers->add(['Authorization' => 'Bearer 123']); - - $this->handler->handle($this->simpleJob()); + // Act + dispatch(new SimpleJob()); - Mail::assertSent(TestMailable::class); + // Assert + $this->assertLogContains('SimpleJob:success'); } /** @test */ public function after_max_attempts_it_will_log_to_failed_table() { - $this->request->headers->add(['X-Cloudtasks-Queuename' => 'my-queue']); - - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 1]); - try { - $this->handler->handle($this->failingJob()); - } catch (\Throwable $e) { - // - } - - $this->assertCount(0, DB::table('failed_jobs')->get()); - - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 2]); - try { - $this->handler->handle($this->failingJob()); - } catch (\Throwable $e) { - // - } + // Act + $this->assertDatabaseCount('failed_jobs', 0); + dispatch(new FailingJob()); + $this->sleep(500); + // Assert + $this->assertDatabaseCount('failed_jobs', 1); $this->assertDatabaseHas('failed_jobs', [ - 'connection' => 'my-cloudtasks-connection', - 'queue' => 'my-queue', - 'payload' => rtrim($this->failingJobPayload()), + 'connection' => 'cloudtasks', + 'queue' => 'barbequeue', ]); } diff --git a/tests/TestCase.php b/tests/TestCase.php index f0fdf78..762b0da 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,31 +2,47 @@ namespace Tests; +use Illuminate\Support\Facades\DB; +use Google\Cloud\Tasks\V2\CloudTasksClient; +use Mockery; + class TestCase extends \Orchestra\Testbench\TestCase { - public static $migrated = false; + /** + * @var \Mockery\Mock $client + */ + public $client; protected function setUp(): void { parent::setUp(); - // There is probably a more sane way to do this - if (!static::$migrated) { - if (file_exists(database_path('database.sqlite'))) { - unlink(database_path('database.sqlite')); - } - - touch(database_path('database.sqlite')); - - foreach(glob(database_path('migrations/*.php')) as $file) { - unlink($file); - } - - $this->artisan('queue:failed-table'); - $this->artisan('migrate'); + $this->forwardToEmulatorClient(); + } - static::$migrated = true; - } + /** + * Forward the Tasks Client to the local emulator. + * + * @return void + */ + private function forwardToEmulatorClient(): void + { + $this->client = $this->instance( + CloudTasksClient::class, + Mockery::mock( + new CloudTasksClient([ + 'apiEndpoint' => 'localhost:8123', + 'transport' => 'grpc', + 'transportConfig' => [ + 'grpc' => [ + 'stubOpts' => [ + 'credentials' => \Grpc\ChannelCredentials::createInsecure() + ] + ] + ] + ]) + )->makePartial() + ); } /** @@ -62,10 +78,10 @@ protected function getEnvironmentSetUp($app) $app['config']->set('queue.default', 'my-cloudtasks-connection'); $app['config']->set('queue.connections.my-cloudtasks-connection', [ 'driver' => 'cloudtasks', - 'queue' => 'test-queue', - 'project' => 'test-project', + 'queue' => 'barbequeue', + 'project' => 'my-test-project', 'location' => 'europe-west6', - 'handler' => 'https://localhost/my-handler', + 'handler' => env('CLOUD_TASKS_HANDLER', 'http://docker.for.mac.localhost:8080/handle-task'), 'service_account_email' => 'info@stackkit.io', ]); } @@ -74,4 +90,66 @@ protected function setConfigValue($key, $value) { $this->app['config']->set('queue.connections.my-cloudtasks-connection.' . $key, $value); } + + protected function sleep(int $ms) + { + usleep($ms * 1000); + } + + public function clearTables() + { + DB::table('failed_jobs')->truncate(); + DB::table('stackkit_cloud_tasks')->truncate(); + } + + protected function logFilePath(): string + { + return __DIR__ . '/laravel/storage/logs/laravel.log'; + } + + protected function clearLaravelStorageFile() + { + if (!file_exists($this->logFilePath())) { + touch($this->logFilePath()); + return; + } + + file_put_contents($this->logFilePath(), ''); + } + + protected function assertLogEmpty() + { + $this->assertEquals('', file_get_contents($this->logFilePath())); + } + + protected function assertLogContains(string $contains) + { + $attempts = 0; + + while (true) { + $attempts++; + + if (file_exists($this->logFilePath())) { + $contents = file_get_contents($this->logFilePath()); + + if (!empty($contents)) { + $this->assertStringContainsString($contains, $contents); + return; + } + } + + if ($attempts >= 50) { + break; + } + + usleep(0.1 * 1000000); + } + + $this->fail('The log file does not contain: ' . $contains); + } + + protected function getLogContents() + { + return file_exists($this->logFilePath()) ? file_get_contents($this->logFilePath()) : ''; + } } From d63bf09ad1151767c200272cd8ef44196df908a2 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Fri, 18 Feb 2022 16:46:08 +0100 Subject: [PATCH 004/258] Redo tests --- README.md | 12 - docker-compose.yml | 8 +- phpunit.xml | 7 +- setup-test-env.php | 5 +- src/CloudTasksApi.php | 20 + src/CloudTasksApiConcrete.php | 42 ++ src/CloudTasksApiContract.php | 16 + src/CloudTasksApiFake.php | 87 ++++ src/CloudTasksJob.php | 11 +- src/CloudTasksQueue.php | 12 +- src/CloudTasksServiceProvider.php | 54 +- src/LogFake.php | 68 +++ src/MonitoringService.php | 13 +- src/OpenIdVerificator.php | 112 +---- src/OpenIdVerificatorConcrete.php | 26 + src/OpenIdVerificatorFake.php | 26 + src/TaskCreated.php | 17 + src/TaskHandler.php | 156 +----- tests/GooglePublicKeyTest.php | 89 ---- tests/QueueTest.php | 166 +++---- tests/Support/TestMailable.php | 10 - tests/Support/failing-job-payload.json | 1 - tests/Support/self-signed-private-key.txt | 15 + .../self-signed-public-key-as-jwk.json | 12 + tests/Support/self-signed-public-key.txt | 6 + tests/Support/test-job-payload.json | 1 - tests/TaskHandlerTest.php | 463 ++++++++---------- tests/TestCase.php | 160 +++--- 28 files changed, 811 insertions(+), 804 deletions(-) create mode 100644 src/CloudTasksApi.php create mode 100644 src/CloudTasksApiConcrete.php create mode 100644 src/CloudTasksApiContract.php create mode 100644 src/CloudTasksApiFake.php create mode 100644 src/LogFake.php create mode 100644 src/OpenIdVerificatorConcrete.php create mode 100644 src/OpenIdVerificatorFake.php create mode 100644 src/TaskCreated.php delete mode 100644 tests/GooglePublicKeyTest.php delete mode 100644 tests/Support/TestMailable.php delete mode 100644 tests/Support/failing-job-payload.json create mode 100644 tests/Support/self-signed-private-key.txt create mode 100644 tests/Support/self-signed-public-key-as-jwk.json create mode 100644 tests/Support/self-signed-public-key.txt delete mode 100644 tests/Support/test-job-payload.json diff --git a/README.md b/README.md index 63b09c4..9f9d9c7 100644 --- a/README.md +++ b/README.md @@ -195,15 +195,3 @@ This package verifies that the token is digitally signed by Google. Only Google More information about OpenID Connect: https://developers.google.com/identity/protocols/oauth2/openid-connect - -# Running tests - -The test suite uses a emulated version of Google Tasks, thanks to aertje/cloud-tasks-emulator. - -To start running the tests locally, first start all Docker services: - -``` -docker compose up -d -``` - -This will start the emulator, MySQL and Postgres. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index daab881..9ee716b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,4 @@ services: - gcloud-tasks-emulator: - image: ghcr.io/aertje/cloud-tasks-emulator:latest - command: -host 0.0.0.0 -openid-issuer http://localhost:8980 -port 8123 -queue "projects/my-test-project/locations/europe-west6/queues/barbequeue" - ports: - - "${TASKS_PORT:-8123}:8123" - - 8980:8980 mysql: image: mysql:8 ports: @@ -18,4 +12,4 @@ services: - 5432:5432 environment: POSTGRES_PASSWORD: 'my-secret-pw' - POSTGRES_DB: 'cloudtasks' \ No newline at end of file + POSTGRES_DB: 'cloudtasks' diff --git a/phpunit.xml b/phpunit.xml index dd7aa5b..0ad6df8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,10 +10,8 @@ stopOnFailure="false"> - tests/ConfigTest.php - tests/GooglePublicKeyTest.php - tests/QueueTest.php - tests/TaskHandlerTest.php + ./tests/ConfigTest.php + ./tests/TaskHandlerTest.php @@ -24,7 +22,6 @@ - diff --git a/setup-test-env.php b/setup-test-env.php index 3bf18f5..453b0cd 100644 --- a/setup-test-env.php +++ b/setup-test-env.php @@ -57,6 +57,7 @@ function env() { 'handler' => 'http://docker.for.mac.localhost:8080/handle-task', 'service_account_email' => 'info@stackkit.io', ]; + $queue['failed']['driver'] = 'database-uuids'; file_put_contents('./tests/laravel/config/queue.php', 'client = $client; + } + + public function getRetryConfig(string $queueName): RetryConfig + { + return $this->client->getQueue($queueName)->getRetryConfig(); + } + + public function createTask(string $queueName, Task $task): Task + { + // TODO: Implement createTask() method. + } + + public function deleteTask(string $taskName): void + { + // TODO: Implement deleteTask() method. + } + + public function getRetryUntilTimestamp(CloudTasksJob $job): ?int + { + // TODO: Implement getRetryUntilTimestamp() method. + } +} diff --git a/src/CloudTasksApiContract.php b/src/CloudTasksApiContract.php new file mode 100644 index 0000000..f6b655e --- /dev/null +++ b/src/CloudTasksApiContract.php @@ -0,0 +1,16 @@ +setMinBackoff((new Duration(['seconds' => 0]))) + ->setMaxBackoff((new Duration(['seconds' => 0]))); + + return $retryConfig; + } + + public function createTask(string $queueName, Task $task): Task + { + $task->setName(Str::uuid()->toString()); + + $this->createdTasks[] = compact('queueName', 'task'); + + return $task; + } + + public function deleteTask(string $taskName): void + { + $this->deletedTasks[] = $taskName; + } + + public function getRetryUntilTimestamp(CloudTasksJob $job): ?int + { + return null; + } + + public function assertTaskDeleted(string $taskName): void + { + $taskUuids = array_map(function ($fullTaskName) { + return Arr::last(explode('/', $fullTaskName)); + }, $this->deletedTasks); + + Assert::assertTrue( + in_array($taskName, $taskUuids), + 'The task [' . $taskName . '] should have been deleted but it is not.' + ); + } + + public function assertTaskNotDeleted(string $taskName): void + { + $taskUuids = array_map(function ($fullTaskName) { + return Arr::last(explode('/', $fullTaskName)); + }, $this->deletedTasks); + + Assert::assertTrue( + ! in_array($taskName, $taskUuids), + 'The task [' . $taskName . '] should not have been deleted but it was.' + ); + } + + public function assertDeletedTaskCount(int $count): void + { + Assert::assertCount($count, $this->deletedTasks); + } + + public function assertTaskCreated(Closure $closure): void + { + $count = count(array_filter($this->createdTasks, function ($createdTask) use ($closure) { + return $closure($createdTask['task'], $createdTask['queueName']); + })); + + Assert::assertTrue($count > 0, 'Task was not created.'); + } +} diff --git a/src/CloudTasksJob.php b/src/CloudTasksJob.php index ba000a8..35b0af7 100644 --- a/src/CloudTasksJob.php +++ b/src/CloudTasksJob.php @@ -17,13 +17,15 @@ class CloudTasksJob extends LaravelJob implements JobContract /** * @var CloudTasksQueue */ - private $cloudTasksQueue; + public $cloudTasksQueue; public function __construct($job, CloudTasksQueue $cloudTasksQueue) { $this->job = $job; $this->container = Container::getInstance(); $this->cloudTasksQueue = $cloudTasksQueue; + $command = unserialize($job['data']['command']); + $this->queue = $command->queue; } public function getJobId() @@ -87,4 +89,11 @@ public function delete() $this->cloudTasksQueue->delete($this); } + + public function fire() + { + $this->attempts++; + + parent::fire(); + } } diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index d67ee50..49b7117 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -18,7 +18,7 @@ class CloudTasksQueue extends LaravelQueue implements QueueContract private $client; private $default; - private $config; + public $config; public function __construct(array $config, CloudTasksClient $client) { @@ -75,7 +75,9 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) MonitoringService::make()->addToMonitor($queue, $task); - $this->client->createTask($queueName, $task); + $createdTask = CloudTasksApi::createTask($queueName, $task); + + event(new TaskCreated($createdTask)); } public function pop($queue = null) @@ -100,14 +102,16 @@ public function delete(CloudTasksJob $job) { $config = $this->config; + $queue = $job->getQueue() ?: $this->config['queue']; // @todo: make this a helper method somewhere. + $taskName = $this->client->taskName( $config['project'], $config['location'], - $job->getQueue(), + $queue, request()->header('X-Cloudtasks-Taskname') ); - $this->client->deleteTask($taskName); + CloudTasksApi::deleteTask($taskName); } /** diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index e70e6f0..5a43718 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -4,6 +4,10 @@ use Google\Cloud\Tasks\V2\CloudTasksClient; use \Grpc\ChannelCredentials; +use Illuminate\Queue\Events\JobExceptionOccurred; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\QueueManager; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Gate; @@ -21,6 +25,7 @@ public function boot(QueueManager $queue, Router $router) $this->registerAssets(); $this->registerMigrations(); $this->registerRoutes($router); + $this->registerMonitoring(); } /** @@ -57,18 +62,11 @@ protected function gate() private function registerClient() { $this->app->singleton(CloudTasksClient::class, function () { - return new CloudTasksClient([ - 'apiEndpoint' => 'localhost:8123', - 'transport' => 'grpc', - 'transportConfig' => [ - 'grpc' => [ - 'stubOpts' => [ - 'credentials' => ChannelCredentials::createInsecure() - ] - ] - ] - ]); + return new CloudTasksClient(); }); + + $this->app->bind('open-id-verificator', OpenIdVerificatorConcrete::class); + $this->app->bind('cloud-tasks-api', CloudTasksApiConcrete::class); } private function registerConnector(QueueManager $queue) @@ -122,4 +120,38 @@ private function registerRoutes(Router $router) $router->get('cloud-tasks-api/task/{uuid}', [CloudTasksApiController::class, 'task']); }); } + + private function registerMonitoring() + { + app('events')->listen(JobProcessing::class, function (JobProcessing $event) { + MonitoringService::make()->markAsRunning( + $event->job->uuid() + ); + }); + + app('events')->listen(JobProcessed::class, function (JobProcessed $event) { + MonitoringService::make()->markAsSuccessful( + $event->job->uuid() + ); + }); + + app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { + MonitoringService::make()->markAsError( + $event + ); + }); + + app('events')->listen(JobFailed::class, function ($event) { + MonitoringService::make()->markAsFailed( + $event + ); + + $config = $event->job->cloudTasksQueue->config; + + app('queue.failer')->log( + $config['connection'], $event->job->getQueue() ?: $config['queue'], + $event->job->getRawBody(), $event->exception + ); + }); + } } diff --git a/src/LogFake.php b/src/LogFake.php new file mode 100644 index 0000000..427c564 --- /dev/null +++ b/src/LogFake.php @@ -0,0 +1,68 @@ +loggedMessages[] = $message; + } + + public function alert(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function critical(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function error(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function warning(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function notice(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function info(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function debug(\Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function log($level, \Stringable|string $message, array $context = []): void + { + $this->loggedMessages[] = $message; + } + + public function channel() + { + return $this; + } + + public function assertLogged(string $message) + { + PHPUnit::assertTrue(in_array($message, $this->loggedMessages), 'The message [' . $message . '] was not logged.'); + } +} diff --git a/src/MonitoringService.php b/src/MonitoringService.php index 92cb8a5..dc944ef 100644 --- a/src/MonitoringService.php +++ b/src/MonitoringService.php @@ -72,8 +72,15 @@ public function markAsSuccessful($uuid) public function markAsError(JobExceptionOccurred $event) { $task = StackkitCloudTask::whereTaskUuid($event->job->uuid()) - ->where('status', '!=', 'failed') - ->firstOrFail(); + ->first(); + + if (!$task) { + return; + } + + if ($task->status === 'failed') { + return; + } $task->status = 'error'; $metadata = $task->getMetadata(); @@ -115,4 +122,4 @@ private function getTaskUuid(Task $task) { return json_decode($task->getHttpRequest()->getBody())->uuid; } -} \ No newline at end of file +} diff --git a/src/OpenIdVerificator.php b/src/OpenIdVerificator.php index 263e3b0..185186b 100644 --- a/src/OpenIdVerificator.php +++ b/src/OpenIdVerificator.php @@ -1,116 +1,20 @@ guzzle = $guzzle; - $this->rsa = $rsa; - $this->jwt = $jwt; - } - - public function decodeOpenIdToken($openIdToken, $cache = false) - { - if (!$cache) { - $this->forgetFromCache(); - } - - $publicKeys = $this->getPublicKeys(); - $exception = null; - - foreach ($publicKeys as $publicKey) { - try { - return $this->jwt->decode($openIdToken, $publicKey, ['RS256']); - } catch (SignatureInvalidException $e) { - $exception = $e; - } - } - - if ($exception instanceof SignatureInvalidException) { - throw $exception; - } - } - - public function getPublicKeys() - { - $v3Certs = Cache::get(self::V3_CERTS); - - if (is_null($v3Certs)) { - $v3Certs = $this->getFreshCertificates(); - Cache::put(self::V3_CERTS, $v3Certs, Carbon::now()->addSeconds($this->maxAge[self::URL_OPENID_CONFIG])); - } - - $publicKeys = []; - - foreach ($v3Certs as $v3Cert) { - $publicKeys[] = $this->extractPublicKeyFromCertificate($v3Cert); - } - - return $publicKeys; - } - - private function getFreshCertificates() - { - $jwksUri = $this->callApiAndReturnValue(self::URL_OPENID_CONFIG, 'jwks_uri'); - - return $this->callApiAndReturnValue($jwksUri, 'keys'); - } - - private function extractPublicKeyFromCertificate($certificate) - { - $modulus = new BigInteger(JWT::urlsafeB64Decode($certificate['n']), 256); - $exponent = new BigInteger(JWT::urlsafeB64Decode($certificate['e']), 256); - - $this->rsa->loadKey(compact('modulus', 'exponent')); - - return $this->rsa->getPublicKey(); - } - - private function callApiAndReturnValue($url, $value) - { - $response = $this->guzzle->get($url); - - $data = json_decode($response->getBody(), true); - - $maxAge = 0; - foreach ($response->getHeader('Cache-Control') as $line) { - preg_match('/max-age=(\d+)/', $line, $matches); - $maxAge = isset($matches[1]) ? (int) $matches[1] : 0; - } - - $this->maxAge[$url] = $maxAge; - - return Arr::get($data, $value); - } - - public function isCached() + protected static function getFacadeAccessor() { - return Cache::has(self::V3_CERTS); + return 'open-id-verificator'; } - public function forgetFromCache() + public static function fake(): void { - Cache::forget(self::V3_CERTS); + self::swap(new OpenIdVerificatorFake()); } } diff --git a/src/OpenIdVerificatorConcrete.php b/src/OpenIdVerificatorConcrete.php new file mode 100644 index 0000000..01f4c0b --- /dev/null +++ b/src/OpenIdVerificatorConcrete.php @@ -0,0 +1,26 @@ +verify( + $token, + [ + 'audience' => $config['handler'], + 'throwException' => true, + ] + ); + } +} diff --git a/src/OpenIdVerificatorFake.php b/src/OpenIdVerificatorFake.php new file mode 100644 index 0000000..f109207 --- /dev/null +++ b/src/OpenIdVerificatorFake.php @@ -0,0 +1,26 @@ +verify( + $token, + [ + 'audience' => $config['handler'], + 'throwException' => true, + 'certsLocation' => __DIR__ . '/../tests/Support/self-signed-public-key-as-jwk.json', + ] + ); + } +} diff --git a/src/TaskCreated.php b/src/TaskCreated.php new file mode 100644 index 0000000..63055e0 --- /dev/null +++ b/src/TaskCreated.php @@ -0,0 +1,17 @@ +task = $task; + } +} diff --git a/src/TaskHandler.php b/src/TaskHandler.php index 0ab22ed..ea7c96c 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -2,21 +2,12 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\Attempt; use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; -use Illuminate\Http\Request; -use Illuminate\Queue\Events\JobExceptionOccurred; -use Illuminate\Queue\Events\JobFailed; -use Illuminate\Queue\Events\JobProcessed; -use Illuminate\Queue\Events\JobProcessing; -use Illuminate\Queue\Worker; use Illuminate\Queue\WorkerOptions; class TaskHandler { - private $request; - private $publicKey; private $config; /** @@ -29,11 +20,9 @@ class TaskHandler */ private $retryConfig = null; - public function __construct(CloudTasksClient $client, Request $request, OpenIdVerificator $publicKey) + public function __construct(CloudTasksClient $client) { $this->client = $client; - $this->request = $request; - $this->publicKey = $publicKey; } /** @@ -48,9 +37,7 @@ public function handle($task = null) $this->setQueue(); - $this->authorizeRequest(); - - $this->listenForEvents(); + OpenIdVerificator::verify(request()->bearerToken(), $this->config); $this->handleTask($task); } @@ -70,47 +57,6 @@ private function setQueue() $this->queue = new CloudTasksQueue($this->config, $this->client); } - /** - * @throws CloudTasksException - */ - public function authorizeRequest() - { - if (!$this->request->hasHeader('Authorization')) { - throw new CloudTasksException('Missing [Authorization] header'); - } - - $openIdToken = $this->request->bearerToken(); - - $decodedToken = $this->publicKey->decodeOpenIdToken($openIdToken); - - $this->validateToken($decodedToken); - } - - /** - * https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken - * - * @param $openIdToken - * @throws CloudTasksException - */ - protected function validateToken($openIdToken) - { - $allowedIssuers = app()->runningUnitTests() - ? ['http://localhost:8980/'] - : ['https://accounts.google.com/', 'accounts.google.com']; - - if (!in_array($openIdToken->iss, $allowedIssuers)) { - throw new CloudTasksException('The given OpenID token is not valid'); - } - - if ($openIdToken->aud != $this->config['handler']) { - throw new CloudTasksException('The given OpenID token is not valid'); - } - - if ($openIdToken->exp < time()) { - throw new CloudTasksException('The given OpenID token has expired'); - } - } - /** * @throws CloudTasksException */ @@ -131,38 +77,6 @@ private function captureTask() return $task; } - private function listenForEvents() - { - app('events')->listen(JobProcessing::class, function (JobProcessing $event) { - MonitoringService::make()->markAsRunning( - $event->job->uuid() - ); - }); - - app('events')->listen(JobProcessed::class, function (JobProcessed $event) { - MonitoringService::make()->markAsSuccessful( - $event->job->uuid() - ); - }); - - app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { - MonitoringService::make()->markAsError( - $event - ); - }); - - app('events')->listen(JobFailed::class, function ($event) { - MonitoringService::make()->markAsFailed( - $event - ); - - app('queue.failer')->log( - $this->config['connection'], $event->job->getQueue(), - $event->job->getRawBody(), $event->exception - ); - }); - } - /** * @param $task * @throws CloudTasksException @@ -171,75 +85,27 @@ private function handleTask($task) { $job = new CloudTasksJob($task, $this->queue); - $this->loadQueueRetryConfig(); + $this->loadQueueRetryConfig($job); - $job->setAttempts(request()->header('X-CloudTasks-TaskRetryCount') + 1); - $job->setQueue(request()->header('X-Cloudtasks-Queuename')); + $job->setAttempts((int) request()->header('X-CloudTasks-TaskExecutionCount')); $job->setMaxTries($this->retryConfig->getMaxAttempts()); // If the job is being attempted again we also check if a // max retry duration has been set. If that duration // has passed, it should stop trying altogether. - if ($job->attempts() > 1) { - $job->setRetryUntil($this->getRetryUntilTimestamp($job)); + if ($job->attempts() > 0) { + $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp($job)); } - $worker = $this->getQueueWorker(); - - $worker->process($this->config['connection'], $job, new WorkerOptions()); - } - - private function loadQueueRetryConfig() - { - $queueName = $this->client->queueName( - $this->config['project'], - $this->config['location'], - request()->header('X-Cloudtasks-Queuename') - ); - - $this->retryConfig = $this->client->getQueue($queueName)->getRetryConfig(); - - // @todo: Need to figure out how to configure this in the emulator itself instead of doing it here. - if (app()->runningUnitTests()) { - $this->retryConfig->setMaxAttempts(3); - $this->retryConfig->setMinBackoff(new \Google\Protobuf\Duration(['seconds' => 0])); - $this->retryConfig->setMaxBackoff(new \Google\Protobuf\Duration(['seconds' => 0])); - } + app('queue.worker')->process($this->config['connection'], $job, new WorkerOptions()); } - private function getRetryUntilTimestamp(CloudTasksJob $job) + private function loadQueueRetryConfig(CloudTasksJob $job) { - $task = $this->client->getTask( - $this->client->taskName( - $this->config['project'], - $this->config['location'], - $job->getQueue(), - request()->header('X-Cloudtasks-Taskname') - ) - ); + $queue = $job->getQueue() ?: $this->config['queue']; - $attempt = $task->getFirstAttempt(); + $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); - if (!$attempt instanceof Attempt) { - return null; - } - - if (! $this->retryConfig->hasMaxRetryDuration()) { - return null; - } - - $maxDurationInSeconds = $this->retryConfig->getMaxRetryDuration()->getSeconds(); - - $firstAttemptTimestamp = $attempt->getDispatchTime()->toDateTime()->getTimestamp(); - - return $firstAttemptTimestamp + $maxDurationInSeconds; - } - - /** - * @return Worker - */ - private function getQueueWorker() - { - return app('queue.worker'); + $this->retryConfig = CloudTasksApi::getRetryConfig($queueName); } } diff --git a/tests/GooglePublicKeyTest.php b/tests/GooglePublicKeyTest.php deleted file mode 100644 index 33be064..0000000 --- a/tests/GooglePublicKeyTest.php +++ /dev/null @@ -1,89 +0,0 @@ -guzzle = Mockery::mock(new Client()); - - $this->publicKey = new OpenIdVerificator($this->guzzle, new RSA(), new JWT()); - } - - /** @test */ - public function it_fetches_the_gcloud_public_key() - { - $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $this->publicKey->getPublicKeys()); - } - - /** @test */ - public function it_caches_the_gcloud_public_key() - { - $this->assertFalse($this->publicKey->isCached()); - - $this->publicKey->getPublicKeys(); - - $this->assertTrue($this->publicKey->isCached()); - } - - /** @test */ - public function it_will_return_the_cached_gcloud_public_key() - { - Event::fake(); - - $this->publicKey->getPublicKeys(); - - Event::assertDispatched(CacheMissed::class); - Event::assertDispatched(KeyWritten::class); - - $this->publicKey->getPublicKeys(); - - Event::assertDispatched(CacheHit::class); - - $this->guzzle->shouldHaveReceived('get')->twice(); - } - - /** @test */ - public function public_key_is_cached_according_to_cache_control_headers() - { - Event::fake(); - - $this->publicKey->getPublicKeys(); - - $this->publicKey->getPublicKeys(); - - Carbon::setTestNow(Carbon::now()->addSeconds(3600)); - $this->publicKey->getPublicKeys(); - - Carbon::setTestNow(Carbon::now()->addSeconds(5)); - $this->publicKey->getPublicKeys(); - - Event::assertDispatched(CacheMissed::class, 2); - Event::assertDispatched(KeyWritten::class, 2); - - } -} diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 57ad95c..77c1426 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -1,125 +1,119 @@ http = $this->instance( - HttpRequest::class, - Mockery::mock(new HttpRequest)->makePartial() - ); - } - - /** @test */ public function a_http_request_with_the_handler_url_is_made() { - SimpleJob::dispatch(); + // Arrange + CloudTasksApi::fake(); - $this->http - ->shouldHaveReceived('setUrl') - ->with('http://docker.for.mac.localhost:8080/handle-task') - ->once(); + // Act + $this->dispatch(new SimpleJob()); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getUrl() === 'http://docker.for.mac.localhost:8080/handle-task'; + }); } - /** @test */ + /** + * @test + */ public function it_posts_to_the_handler() { - SimpleJob::dispatch(); + // Arrange + CloudTasksApi::fake(); - $this->http->shouldHaveReceived('setHttpMethod')->with(HttpMethod::POST)->once(); + // Act + $this->dispatch(new SimpleJob()); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getHttpMethod() === HttpMethod::POST; + }); } - /** @test */ + /** + * @test + */ public function it_posts_the_serialized_job_payload_to_the_handler() { - $job = new SimpleJob(); - $job->dispatch(); + // Arrange + CloudTasksApi::fake(); - $this->http->shouldHaveReceived('setBody')->with(Mockery::on(function ($payload) use ($job) { - $decoded = json_decode($payload, true); + // Act + $this->dispatch($job = new SimpleJob()); - if ($decoded['displayName'] != 'Tests\Support\SimpleJob') { - return false; - } + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task) use ($job): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); - if ($decoded['job'] != 'Illuminate\Queue\CallQueuedHandler@call') { - return false; - } - - if ($decoded['data']['commandName'] != 'Tests\Support\SimpleJob') { - return false; - } - - if ($decoded['data']['command'] != serialize($job)) { - return false; - } - - return true; - })); + return $decoded['displayName'] === SimpleJob::class + && $decoded['job'] === 'Illuminate\Queue\CallQueuedHandler@call' + && $decoded['data']['command'] === serialize($job); + }); } - /** @test */ + /** + * @test + */ public function it_will_set_the_scheduled_time_when_dispatching_later() { - $task = $this->instance( - Task::class, - Mockery::mock(new Task)->makePartial() - ); - - $inFiveMinutes = Carbon::now()->addMinutes(5); + // Arrange + CloudTasksApi::fake(); - SimpleJob::dispatch()->delay($inFiveMinutes); + // Act + $inFiveMinutes = now()->addMinutes(5); + $this->dispatch((new SimpleJob())->delay($inFiveMinutes)); - $task->shouldHaveReceived('setScheduleTime') - ->once() - ->with(Mockery::on(function (Timestamp $timestamp) use ($inFiveMinutes) { - return $timestamp->getSeconds() === $inFiveMinutes->timestamp; - })); + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task) use ($inFiveMinutes): bool { + return $task->getScheduleTime()->getSeconds() === $inFiveMinutes->timestamp; + }); } - /** @test */ + /** + * @test + */ public function it_posts_the_task_the_correct_queue() { - SimpleJob::dispatch(); - - $this->client - ->shouldHaveReceived('createTask') - ->withArgs(function ($queueName) { - return $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; - }); - } - - /** @test */ - public function it_posts_the_correct_task_the_queue() - { - SimpleJob::dispatch(); - - $this->client - ->shouldHaveReceived('createTask') - ->withArgs(function ($queueName, Task $task) { - return strpos( - $task->getHttpRequest()->getBody(), - 'SimpleJob' - ) !== false; - }); + // Arrange + CloudTasksApi::fake(); + + // Act + $this->dispatch((new SimpleJob())); + $this->dispatch((new FailingJob())->onQueue('my-special-queue')); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); + $command = unserialize($decoded['data']['command']); + + return $decoded['displayName'] === SimpleJob::class + && $command->queue === null + && $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; + }); + + CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); + $command = unserialize($decoded['data']['command']); + + return $decoded['displayName'] === FailingJob::class + && $command->queue === 'my-special-queue' + && $queueName === 'projects/my-test-project/locations/europe-west6/queues/my-special-queue'; + }); } } diff --git a/tests/Support/TestMailable.php b/tests/Support/TestMailable.php deleted file mode 100644 index cab9d6b..0000000 --- a/tests/Support/TestMailable.php +++ /dev/null @@ -1,10 +0,0 @@ -clearLaravelStorageFile(); - $this->clearTables(); + CloudTasksApi::fake(); } - /** @test */ - public function it_runs_the_incoming_job() + /** + * @test + */ + public function the_task_handler_needs_an_open_id_token() { - // Act - dispatch(new SimpleJob()); - // Assert - $this->assertLogContains('SimpleJob:success'); - } + $this->expectException(CloudTasksException::class); + $this->expectExceptionMessage('Missing [Authorization] header'); - /** @test */ - public function after_max_attempts_it_will_log_to_failed_table() - { // Act - $this->assertDatabaseCount('failed_jobs', 0); - dispatch(new FailingJob()); - $this->sleep(500); - - // Assert - $this->assertDatabaseCount('failed_jobs', 1); - $this->assertDatabaseHas('failed_jobs', [ - 'connection' => 'cloudtasks', - 'queue' => 'barbequeue', - ]); + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function after_max_attempts_it_will_delete_the_task() + /** + * @test + */ + public function the_task_handler_throws_an_exception_if_the_id_token_is_invalid() { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 2]); + // Arrange + request()->headers->set('Authorization', 'Bearer my-invalid-token'); - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + // Assert + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Wrong number of segments'); - $this->cloudTasksClient->shouldHaveReceived('deleteTask')->once(); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function after_max_retry_until_it_will_delete_the_task() + /** + * @test + */ + public function it_validates_the_token_expiration() { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 1]); - - $this->cloudTasksClient - ->shouldReceive('getTask') - ->byDefault() - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time() - 29, - ])); - } - }); - - rescue(function () { - $this->handler->handle($this->failingJob()); + // Arrange + OpenIdVerificator::fake(); + $this->addIdTokenToHeader(function (array $base) { + return ['exp' => time() - 5] + $base; }); - $this->cloudTasksClient->shouldNotHaveReceived('deleteTask'); + // Assert + $this->expectException(ExpiredException::class); + $this->expectExceptionMessage('Expired token'); - $this->cloudTasksClient->shouldReceive('getTask') - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time() - 30, - ])); - } - }); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); + } - rescue(function () { - $this->handler->handle($this->failingJob()); + /** + * @test + */ + public function it_validates_the_token_aud() + { + // Arrange + OpenIdVerificator::fake(); + $this->addIdTokenToHeader(function (array $base) { + return ['aud' => 'invalid-aud'] + $base; }); - $this->cloudTasksClient->shouldHaveReceived('deleteTask')->once(); + // Assert + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Audience does not match'); + + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function test_unlimited_max_attempts() + /** + * @test + */ + public function it_can_run_a_task() { - $this->cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return -1; - } - - public function hasMaxRetryDuration() { - return false; - } - }; - } - }); - - for ($i = 0; $i < 50; $i++) { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => $i]); - - rescue(function () { - $this->handler->handle($this->failingJob()); - }); - - $this->cloudTasksClient->shouldNotHaveReceived('deleteTask'); - } + // Arrange + OpenIdVerificator::fake(); + Log::swap(new LogFake()); + Event::fake([JobProcessing::class, JobProcessed::class]); + + // Act + $this->dispatch(new SimpleJob())->run(); + + // Assert + Event::assertDispatchedTimes(JobProcessing::class, 1); + Event::assertDispatchedTimes(JobProcessed::class, 1); + Event::assertDispatched(JobProcessed::class, function (JobProcessed $event) { + return $event->job->resolveName() === 'Tests\\Support\\SimpleJob'; + }); + Log::assertLogged('SimpleJob:success'); } /** * @test - * @dataProvider whenIsJobFailingProvider */ - public function job_max_attempts_is_ignored_if_has_retry_until($example) + public function after_max_attempts_it_will_log_to_failed_table() { // Arrange - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => $example['retryCount']]); + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); + $job = $this->dispatch(new FailingJob()); - if (array_key_exists('travelSeconds', $example)) { - Carbon::setTestNow(Carbon::now()->addSeconds($example['travelSeconds'])); - } + // Act & Assert + $this->assertDatabaseCount('failed_jobs', 0); - $this->cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class() { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return 3; - } - - public function hasMaxRetryDuration() { - return true; - } - - public function getMaxRetryDuration() { - return new class { - public function getSeconds() { - return 30; - } - }; - } - }; - } - }); - - $this->cloudTasksClient - ->shouldReceive('getTask') - ->byDefault() - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time(), - ])); - } - }); - - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + $job->run(); + $this->assertDatabaseCount('failed_jobs', 0); - if ($example['shouldHaveFailed']) { - $this->cloudTasksClient->shouldHaveReceived('deleteTask'); - } else { - $this->cloudTasksClient->shouldNotHaveReceived('deleteTask'); - } + $job->run(); + $this->assertDatabaseCount('failed_jobs', 0); + + $job->run(); + $this->assertDatabaseCount('failed_jobs', 1); } - public function whenIsJobFailingProvider() + /** + * @test + */ + public function after_max_attempts_it_will_delete_the_task() { - $this->createApplication(); - - // 8.x behavior: if retryUntil, only check that. - // 6.x behavior: if retryUntil, check that, otherwise check maxAttempts - - // max retry count is 3 - // max retryUntil is 30 seconds - - if (version_compare(app()->version(), '8.0.0', '>=')) { - return [ - [ - [ - 'retryCount' => 1, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 2, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 29, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - ]; - } + // Arrange + OpenIdVerificator::fake(); + + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); - return [ - [ - [ - 'retryCount' => 1, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 2, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 29, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - ]; + $job = $this->dispatch(new FailingJob()); + + // Act & Assert + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + + $job->run(); + CloudTasksApi::assertDeletedTaskCount(1); + CloudTasksApi::assertTaskDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 1); } - private function simpleJob() + /** + * @test + */ + public function after_max_retry_until_it_will_log_to_failed_table_and_delete_the_task() { - return json_decode(file_get_contents(__DIR__ . '/Support/test-job-payload.json'), true); + // Arrange + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxRetryDuration(new Duration(['seconds' => 30])) + ); + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(1); + $job = $this->dispatch(new FailingJob()); + + // Act + $job->run(); + + // Assert + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + + // Act + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(1); + $job->run(); + + // Assert + CloudTasksApi::assertDeletedTaskCount(1); + CloudTasksApi::assertTaskDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 1); } - private function failingJobPayload() + /** + * @test + */ + public function test_unlimited_max_attempts() { - return file_get_contents(__DIR__ . '/Support/failing-job-payload.json'); + // Arrange + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + // -1 is a valid option in Cloud Tasks to indicate there is no max. + (new RetryConfig())->setMaxAttempts(-1) + ); + + // Act + $job = $this->dispatch(new FailingJob()); + foreach (range(1, 50) as $attempt) { + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + } } - private function failingJob() + /** + * @test + */ + public function test_max_attempts_in_combination_with_retry_until() { - return json_decode($this->failingJobPayload(), true); + // Laravel 5, 6, 7: check both max_attempts and retry_until before failing a job. + // Laravel 8+: if retry_until, only check that + + // Arrange + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig()) + ->setMaxAttempts(3) + ->setMaxRetryDuration(new Duration(['seconds' => 3])) + ); + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 1)->byDefault(); + Event::fake([JobExceptionOccurred::class, JobFailed::class]); + $job = $this->dispatch(new FailingJob()); + + // Act & Assert + $job->run(); + $job->run(); + + # After 2 attempts both Laravel versions should report the same: 2 errors and 0 failures. + Event::assertDispatchedTimes(JobExceptionOccurred::class, 2); + Event::assertNotDispatched(JobFailed::class); + + $job->run(); + + # Max attempts was reached + # Laravel 5, 6, 7: fail because max attempts was reached + # Laravel 8+: don't fail because retryUntil has not yet passed. + + if (version_compare(app()->version(), '8.0.0', '<')) { + Event::assertDispatched(JobFailed::class); + return; + } else { + Event::assertNotDispatched(JobFailed::class); + } + + CloudTasksApi::shouldReceive('getRetryUntilTimestamp')->andReturn(time() - 1); + $job->run(); + + Event::assertDispatched(JobFailed::class); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 762b0da..21835a0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,48 +2,28 @@ namespace Tests; +use Closure; +use Firebase\JWT\JWT; +use Google\ApiCore\ApiException; +use Google\Cloud\Tasks\V2\Queue; +use Google\Cloud\Tasks\V2\RetryConfig; +use Google\Cloud\Tasks\V2\Task; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Google\Cloud\Tasks\V2\CloudTasksClient; +use Illuminate\Support\Facades\Event; use Mockery; +use Stackkit\LaravelGoogleCloudTasksQueue\TaskCreated; +use Stackkit\LaravelGoogleCloudTasksQueue\TaskHandler; class TestCase extends \Orchestra\Testbench\TestCase { - /** - * @var \Mockery\Mock $client - */ - public $client; - - protected function setUp(): void - { - parent::setUp(); - - $this->forwardToEmulatorClient(); - } + use DatabaseTransactions; /** - * Forward the Tasks Client to the local emulator. - * - * @return void + * @var \Mockery\Mock|CloudTasksClient $client */ - private function forwardToEmulatorClient(): void - { - $this->client = $this->instance( - CloudTasksClient::class, - Mockery::mock( - new CloudTasksClient([ - 'apiEndpoint' => 'localhost:8123', - 'transport' => 'grpc', - 'transportConfig' => [ - 'grpc' => [ - 'stubOpts' => [ - 'credentials' => \Grpc\ChannelCredentials::createInsecure() - ] - ] - ] - ]) - )->makePartial() - ); - } + public $client; /** * Get package providers. At a minimum this is the package being tested, but also @@ -84,6 +64,7 @@ protected function getEnvironmentSetUp($app) 'handler' => env('CLOUD_TASKS_HANDLER', 'http://docker.for.mac.localhost:8080/handle-task'), 'service_account_email' => 'info@stackkit.io', ]); + $app['config']->set('queue.failed.driver', 'database-uuids'); } protected function setConfigValue($key, $value) @@ -91,65 +72,100 @@ protected function setConfigValue($key, $value) $this->app['config']->set('queue.connections.my-cloudtasks-connection.' . $key, $value); } - protected function sleep(int $ms) + public function dispatch($job) { - usleep($ms * 1000); + $payload = null; + $task = null; + + Event::listen(TaskCreated::class, function (TaskCreated $event) use (&$payload, &$task) { + $payload = json_decode($event->task->getHttpRequest()->getBody(), true); + $task = $event->task; + + request()->headers->set('X-Cloudtasks-Taskname', $task->getName()); + }); + + dispatch($job); + + return new class($payload, $task) { + public array $payload = []; + public Task $task; + + public function __construct(array $payload, Task $task) + { + $this->payload = $payload; + $this->task = $task; + } + + public function run(): void + { + rescue(function (): void { + app(TaskHandler::class)->handle($this->payload); + }); + + $taskExecutionCount = request()->header('X-CloudTasks-TaskExecutionCount', 0); + request()->headers->set('X-CloudTasks-TaskExecutionCount', $taskExecutionCount + 1); + } + + public function runWithoutExceptionHandler(): void + { + app(TaskHandler::class)->handle($this->payload); + + $taskExecutionCount = request()->header('X-CloudTasks-TaskExecutionCount', 0); + request()->headers->set('X-CloudTasks-TaskExecutionCount', $taskExecutionCount + 1); + } + }; } - public function clearTables() + public function runFromPayload(array $payload): void { - DB::table('failed_jobs')->truncate(); - DB::table('stackkit_cloud_tasks')->truncate(); + rescue(function () use ($payload) { + app(TaskHandler::class)->handle($payload); + }); } - protected function logFilePath(): string + public function dispatchAndRun($job): void { - return __DIR__ . '/laravel/storage/logs/laravel.log'; + $this->runFromPayload($this->dispatch($job)); } - protected function clearLaravelStorageFile() + public function assertTaskDeleted(string $taskId): void { - if (!file_exists($this->logFilePath())) { - touch($this->logFilePath()); - return; - } + try { + $this->client->getTask($taskId); - file_put_contents($this->logFilePath(), ''); + $this->fail('Getting the task should throw an exception but it did not.'); + } catch (ApiException $e) { + $this->assertStringContainsString('The task no longer exists', $e->getMessage()); + } } - protected function assertLogEmpty() + public function assertTaskExists(string $taskId): void { - $this->assertEquals('', file_get_contents($this->logFilePath())); + try { + $task = $this->client->getTask($taskId); + + $this->assertInstanceOf(Task::class, $task); + } catch (ApiException $e) { + $this->fail('Task [' . $taskId . '] should exist but it does not (or something else went wrong).'); + } } - protected function assertLogContains(string $contains) + protected function addIdTokenToHeader(?Closure $closure = null): void { - $attempts = 0; - - while (true) { - $attempts++; - - if (file_exists($this->logFilePath())) { - $contents = file_get_contents($this->logFilePath()); - - if (!empty($contents)) { - $this->assertStringContainsString($contains, $contents); - return; - } - } - - if ($attempts >= 50) { - break; - } + $base = [ + 'iss' => 'https://accounts.google.com/', + 'aud' => 'http://docker.for.mac.localhost:8080/handle-task', + 'exp' => time() + 10, + ]; - usleep(0.1 * 1000000); + if ($closure) { + $base = $closure($base); } - $this->fail('The log file does not contain: ' . $contains); - } + $privateKey = file_get_contents(__DIR__ . '/../tests/Support/self-signed-private-key.txt'); - protected function getLogContents() - { - return file_exists($this->logFilePath()) ? file_get_contents($this->logFilePath()) : ''; + $token = JWT::encode($base, $privateKey, 'RS256', 'abc123'); + + request()->headers->set('Authorization', 'Bearer ' . $token); } } From 213024f508fc9912ef3d94f96cc1c156f5165042 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 19 Feb 2022 20:26:47 +0100 Subject: [PATCH 005/258] Fix matrix and drop PHP 7.2 and 7.3 support --- .github/workflows/run-tests.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3f8900b..af6d6f5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,8 +11,8 @@ jobs: strategy: matrix: - php: [8.1, 8.0, 7.4, 7.3, 7.2] - laravel: [8.*, 7.*, 6.*] + php: [8.1, 8.0, 7.4] + laravel: [9.*, 8.*, 7.*, 6.*] os: [ubuntu-latest] include: - laravel: 9.* @@ -24,14 +24,8 @@ jobs: - laravel: 6.* testbench: 4.* exclude: - - laravel: 9.* - php: 7.2 - - laravel: 9.* - php: 7.3 - laravel: 9.* php: 7.4 - - laravel: 8.* - php: 7.2 - laravel: 6.* php: 8.1 - laravel: 7.* From fc5e495001628aaed844eceaf206b9af14ad9f16 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 12:11:03 +0100 Subject: [PATCH 006/258] Add database to Github Actions and add fixes for other Laravel versions --- .github/workflows/run-tests.yml | 15 ++++++++++++++- phpunit.xml | 1 + src/CloudTasksJob.php | 5 +++++ src/CloudTasksQueue.php | 19 ++++++++++++++++++- src/LogFake.php | 20 ++++++++++---------- tests/TaskHandlerTest.php | 24 ++++++++++++------------ tests/TestCase.php | 28 ++++++++++++++++++++++++++++ 7 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index af6d6f5..d80437d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -44,9 +44,22 @@ jobs: extensions: mbstring, dom, fileinfo coverage: none + - name: Set up MySQL + run: | + sudo /etc/init.d/mysql start + mysql -e 'CREATE DATABASE test;' -uroot -proot + - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction --no-suggest - name: Execute tests - run: vendor/bin/phpunit + env: + CI_DB_DRIVER: mysql + CI_DB_HOST: 127.0.0.1 + CI_DB_PORT: 3306 + CI_DB_DATABASE: test + CI_DB_USERNAME: root + CI_DB_PASSWORD: root + run: | + vendor/bin/phpunit diff --git a/phpunit.xml b/phpunit.xml index 0ad6df8..305c677 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + diff --git a/src/CloudTasksJob.php b/src/CloudTasksJob.php index 35b0af7..97aa511 100644 --- a/src/CloudTasksJob.php +++ b/src/CloudTasksJob.php @@ -33,6 +33,11 @@ public function getJobId() return $this->job['uuid']; } + public function uuid(): string + { + return $this->job['uuid']; + } + public function getRawBody() { return json_encode($this->job); diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 49b7117..c8b50be 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -11,6 +11,7 @@ use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Queue\Queue as LaravelQueue; use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Str; class CloudTasksQueue extends LaravelQueue implements QueueContract { @@ -60,7 +61,12 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) $httpRequest = $this->createHttpRequest(); $httpRequest->setUrl($this->config['handler']); $httpRequest->setHttpMethod(HttpMethod::POST); - $httpRequest->setBody($payload); + $httpRequest->setBody( + // Laravel 7+ jobs have a uuid, but Laravel 6 doesn't have it. + // Since we are using and expecting the uuid in some places + // we will add it manually here if it's not present yet. + $this->withUuid($payload) + ); $task = $this->createTask(); $task->setHttpRequest($httpRequest); @@ -80,6 +86,17 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) event(new TaskCreated($createdTask)); } + private function withUuid(string $payload): string + { + $decoded = json_decode($payload, true); + + if (!isset($decoded['uuid'])) { + $decoded['uuid'] = (string) Str::uuid(); + } + + return json_encode($decoded); + } + public function pop($queue = null) { // TODO: Implement pop() method. diff --git a/src/LogFake.php b/src/LogFake.php index 427c564..764e1a7 100644 --- a/src/LogFake.php +++ b/src/LogFake.php @@ -7,51 +7,51 @@ use PHPUnit\Framework\Assert as PHPUnit; use Psr\Log\LoggerInterface; -class LogFake implements LoggerInterface +class LogFake { private array $loggedMessages = []; - public function emergency(\Stringable|string $message, array $context = []): void + public function emergency(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function alert(\Stringable|string $message, array $context = []): void + public function alert(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function critical(\Stringable|string $message, array $context = []): void + public function critical(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function error(\Stringable|string $message, array $context = []): void + public function error(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function warning(\Stringable|string $message, array $context = []): void + public function warning(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function notice(\Stringable|string $message, array $context = []): void + public function notice(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function info(\Stringable|string $message, array $context = []): void + public function info(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function debug(\Stringable|string $message, array $context = []): void + public function debug(string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function log($level, \Stringable|string $message, array $context = []): void + public function log($level, string $message, array $context = []): void { $this->loggedMessages[] = $message; } diff --git a/tests/TaskHandlerTest.php b/tests/TaskHandlerTest.php index c3e645f..29e7afb 100644 --- a/tests/TaskHandlerTest.php +++ b/tests/TaskHandlerTest.php @@ -5,17 +5,21 @@ use Firebase\JWT\ExpiredException; use Google\Cloud\Tasks\V2\RetryConfig; use Google\Protobuf\Duration; +use Illuminate\Database\Eloquent\Model; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Queue; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksException; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksJob; use Stackkit\LaravelGoogleCloudTasksQueue\LogFake; use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; +use Stackkit\LaravelGoogleCloudTasksQueue\StackkitCloudTask; use Tests\Support\FailingJob; use Tests\Support\SimpleJob; use UnexpectedValueException; @@ -107,14 +111,9 @@ public function it_can_run_a_task() Event::fake([JobProcessing::class, JobProcessed::class]); // Act - $this->dispatch(new SimpleJob())->run(); + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); // Assert - Event::assertDispatchedTimes(JobProcessing::class, 1); - Event::assertDispatchedTimes(JobProcessed::class, 1); - Event::assertDispatched(JobProcessed::class, function (JobProcessed $event) { - return $event->job->resolveName() === 'Tests\\Support\\SimpleJob'; - }); Log::assertLogged('SimpleJob:success'); } @@ -243,7 +242,7 @@ public function test_max_attempts_in_combination_with_retry_until() ->setMaxRetryDuration(new Duration(['seconds' => 3])) ); CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 1)->byDefault(); - Event::fake([JobExceptionOccurred::class, JobFailed::class]); + $job = $this->dispatch(new FailingJob()); // Act & Assert @@ -251,8 +250,9 @@ public function test_max_attempts_in_combination_with_retry_until() $job->run(); # After 2 attempts both Laravel versions should report the same: 2 errors and 0 failures. - Event::assertDispatchedTimes(JobExceptionOccurred::class, 2); - Event::assertNotDispatched(JobFailed::class); + $task = StackkitCloudTask::whereTaskUuid($job->payload['uuid'])->firstOrFail(); + $this->assertEquals(2, $task->getNumberOfAttempts()); + $this->assertEquals('error', $task->status); $job->run(); @@ -261,15 +261,15 @@ public function test_max_attempts_in_combination_with_retry_until() # Laravel 8+: don't fail because retryUntil has not yet passed. if (version_compare(app()->version(), '8.0.0', '<')) { - Event::assertDispatched(JobFailed::class); + $this->assertEquals('failed', $task->fresh()->status); return; } else { - Event::assertNotDispatched(JobFailed::class); + $this->assertEquals('error', $task->fresh()->status); } CloudTasksApi::shouldReceive('getRetryUntilTimestamp')->andReturn(time() - 1); $job->run(); - Event::assertDispatched(JobFailed::class); + $this->assertEquals('failed', $task->fresh()->status); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 21835a0..fc819d5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -42,6 +42,17 @@ protected function getPackageProviders($app) ]; } + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(__DIR__ . '/../migrations'); + $this->loadMigrationsFrom(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/migrations'); + } + /** * Define environment setup. * @@ -54,6 +65,17 @@ protected function getEnvironmentSetUp($app) unlink($file); } + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'mysql', + 'host' => env('CI_DB_HOST', env('DB_HOST')), + 'port' => env('CI_DB_PORT', env('DB_PORT')), + 'database' => env('CI_DB_DATABASE', env('DB_DATABASE')), + 'username' => env('CI_DB_USERNAME', env('DB_USERNAME')), + 'password' => env('CI_DB_PASSWORD', env('DB_PASSWORD')), + 'prefix' => '', + ]); + $app['config']->set('cache.default', 'file'); $app['config']->set('queue.default', 'my-cloudtasks-connection'); $app['config']->set('queue.connections.my-cloudtasks-connection', [ @@ -65,6 +87,7 @@ protected function getEnvironmentSetUp($app) 'service_account_email' => 'info@stackkit.io', ]); $app['config']->set('queue.failed.driver', 'database-uuids'); + $app['config']->set('queue.failed.database', 'testbench'); } protected function setConfigValue($key, $value) @@ -168,4 +191,9 @@ protected function addIdTokenToHeader(?Closure $closure = null): void request()->headers->set('Authorization', 'Bearer ' . $token); } + + protected function assertDatabaseCount($table, int $count, $connection = null) + { + $this->assertEquals($count, DB::connection($connection)->table($table)->count()); + } } From 18ecb8a888578e17b78b66e8ac1d20db147f5d3e Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 13:54:20 +0100 Subject: [PATCH 007/258] Implement actual Google Cloud API connector and add tests --- .github/workflows/run-tests.yml | 6 + phpunit.xml | 1 + src/CloudTasksApi.php | 9 ++ src/CloudTasksApiConcrete.php | 35 +++++- src/CloudTasksApiContract.php | 3 +- src/CloudTasksApiFake.php | 9 +- src/TaskHandler.php | 2 +- tests/CloudTasksApiTest.php | 191 ++++++++++++++++++++++++++++++++ 8 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 tests/CloudTasksApiTest.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d80437d..36d1a29 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -61,5 +61,11 @@ jobs: CI_DB_DATABASE: test CI_DB_USERNAME: root CI_DB_PASSWORD: root + CI_CLOUD_TASKS_PROJECT_ID: ${{ secrets.CI_CLOUD_TASKS_PROJECT_ID }} + CI_CLOUD_TASKS_QUEUE: ${{ secrets.CI_CLOUD_TASKS_QUEUE }} + CI_CLOUD_TASKS_LOCATION: ${{ secrets.CI_CLOUD_TASKS_LOCATION }} + CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL: ${{ secrets.CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL }} + CI_SERVICE_ACCOUNT_JSON_KEY: ${{ secrets.CI_SERVICE_ACCOUNT_JSON_KEY }} run: | + echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json vendor/bin/phpunit diff --git a/phpunit.xml b/phpunit.xml index 305c677..3f7717a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -12,6 +12,7 @@ ./tests/ConfigTest.php ./tests/TaskHandlerTest.php + ./tests/CloudTasksApiTest.php diff --git a/src/CloudTasksApi.php b/src/CloudTasksApi.php index dcb179f..bc8f4bd 100644 --- a/src/CloudTasksApi.php +++ b/src/CloudTasksApi.php @@ -4,8 +4,17 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Google\Cloud\Tasks\V2\RetryConfig; +use Google\Cloud\Tasks\V2\Task; use Illuminate\Support\Facades\Facade; +/** + * @method static RetryConfig getRetryConfig(string $queueName) + * @method static Task createTask(string $queueName, Task $task) + * @method static void deleteTask(string $taskName) + * @method static Task getTask(string $taskName) + * @method static int|null getRetryUntilTimestamp(string $taskName) + */ class CloudTasksApi extends Facade { protected static function getFacadeAccessor() diff --git a/src/CloudTasksApiConcrete.php b/src/CloudTasksApiConcrete.php index 3a174fc..7c9e9a0 100644 --- a/src/CloudTasksApiConcrete.php +++ b/src/CloudTasksApiConcrete.php @@ -4,6 +4,7 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Google\Cloud\Tasks\V2\Attempt; use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; @@ -27,16 +28,42 @@ public function getRetryConfig(string $queueName): RetryConfig public function createTask(string $queueName, Task $task): Task { - // TODO: Implement createTask() method. + return $this->client->createTask($queueName, $task); } public function deleteTask(string $taskName): void { - // TODO: Implement deleteTask() method. + $this->client->deleteTask($taskName); } - public function getRetryUntilTimestamp(CloudTasksJob $job): ?int + public function getTask(string $taskName): Task { - // TODO: Implement getRetryUntilTimestamp() method. + return $this->client->getTask($taskName); + } + + + public function getRetryUntilTimestamp(string $taskName): ?int + { + $task = $this->getTask($taskName); + + $attempt = $task->getFirstAttempt(); + + if (!$attempt instanceof Attempt) { + return null; + } + + $queueName = implode('/', array_slice(explode('/', $task->getName()), 0, 6)); + + $retryConfig = $this->getRetryConfig($queueName); + + if (! $retryConfig->hasMaxRetryDuration()) { + return null; + } + + $maxDurationInSeconds = $retryConfig->getMaxRetryDuration()->getSeconds(); + + $firstAttemptTimestamp = $attempt->getDispatchTime()->toDateTime()->getTimestamp(); + + return $firstAttemptTimestamp + $maxDurationInSeconds; } } diff --git a/src/CloudTasksApiContract.php b/src/CloudTasksApiContract.php index f6b655e..d43e1ec 100644 --- a/src/CloudTasksApiContract.php +++ b/src/CloudTasksApiContract.php @@ -12,5 +12,6 @@ interface CloudTasksApiContract public function getRetryConfig(string $queueName): RetryConfig; public function createTask(string $queueName, Task $task): Task; public function deleteTask(string $taskName): void; - public function getRetryUntilTimestamp(CloudTasksJob $job): ?int; + public function getTask(string $taskName): Task; + public function getRetryUntilTimestamp(string $taskName): ?int; } diff --git a/src/CloudTasksApiFake.php b/src/CloudTasksApiFake.php index de39102..da3b56c 100644 --- a/src/CloudTasksApiFake.php +++ b/src/CloudTasksApiFake.php @@ -42,7 +42,14 @@ public function deleteTask(string $taskName): void $this->deletedTasks[] = $taskName; } - public function getRetryUntilTimestamp(CloudTasksJob $job): ?int + public function getTask(string $taskName): Task + { + return (new Task()) + ->setName($taskName); + } + + + public function getRetryUntilTimestamp(string $taskName): ?int { return null; } diff --git a/src/TaskHandler.php b/src/TaskHandler.php index ea7c96c..e8aeb0c 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -94,7 +94,7 @@ private function handleTask($task) // max retry duration has been set. If that duration // has passed, it should stop trying altogether. if ($job->attempts() > 0) { - $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp($job)); + $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp(request()->header('X-Cloudtasks-Taskname'))); } app('queue.worker')->process($this->config['connection'], $job, new WorkerOptions()); diff --git a/tests/CloudTasksApiTest.php b/tests/CloudTasksApiTest.php new file mode 100644 index 0000000..6d390a5 --- /dev/null +++ b/tests/CloudTasksApiTest.php @@ -0,0 +1,191 @@ +fail('Missing [' . $env . '] environment variable.'); + } + } + + $this->setConfigValue('project', env('CI_CLOUD_TASKS_PROJECT_ID')); + $this->setConfigValue('queue', env('CI_CLOUD_TASKS_QUEUE')); + $this->setConfigValue('location', env('CI_CLOUD_TASKS_LOCATION')); + $this->setConfigValue('service_account_email', env('CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL')); + + $this->client = new CloudTasksClient(); + + } + + /** + * @test + */ + public function test_get_retry_config() + { + // Act + $retryConfig = CloudTasksApi::getRetryConfig( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ) + ); + + // Assert + $this->assertInstanceOf(RetryConfig::class, $retryConfig); + $this->assertEquals(2, $retryConfig->getMaxAttempts()); + $this->assertEquals(5, $retryConfig->getMaxRetryDuration()->getSeconds()); + } + + /** + * @test + */ + public function test_create_task() + { + // Arrange + $httpRequest = new HttpRequest(); + $httpRequest->setHttpMethod(HttpMethod::GET); + $httpRequest->setUrl('https://example.com/'); + + $cloudTask = new Task(); + $cloudTask->setHttpRequest($httpRequest); + + // Act + $task = CloudTasksApi::createTask( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ), + $cloudTask + ); + $taskName = $task->getName(); + + // Assert + $this->assertMatchesRegularExpression( + '/projects\/' . env('CI_CLOUD_TASKS_PROJECT_ID') . '\/locations\/' . env('CI_CLOUD_TASKS_LOCATION') . '\/queues\/' . env('CI_CLOUD_TASKS_QUEUE') . '\/tasks\/\d{19,}$/', + $taskName + ); + } + + /** + * @test + */ + public function test_delete_task_on_non_existing_task() + { + // Assert + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Requested entity was not found.'); + + // Act + CloudTasksApi::deleteTask( + $this->client->taskName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE'), + 'non-existing-id' + ), + ); + + } + + /** + * @test + */ + public function test_delete_task() + { + // Arrange + $httpRequest = new HttpRequest(); + $httpRequest->setHttpMethod(HttpMethod::GET); + $httpRequest->setUrl('https://example.com/'); + + $cloudTask = new Task(); + $cloudTask->setHttpRequest($httpRequest); + $cloudTask->setScheduleTime(new Timestamp(['seconds' => time() + 10])); + + $task = CloudTasksApi::createTask( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ), + $cloudTask + ); + + // Act + $fresh = CloudTasksApi::getTask($task->getName()); + $this->assertInstanceOf(Task::class, $fresh); + + CloudTasksApi::deleteTask($task->getName()); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('NOT_FOUND'); + CloudTasksApi::getTask($task->getName()); + } + + /** + * @test + */ + public function test_get_retry_until_timestamp() + { + // Arrange + $httpRequest = new HttpRequest(); + $httpRequest->setHttpMethod(HttpMethod::GET); + $httpRequest->setUrl('https://httpstat.us/500'); + + $cloudTask = new Task(); + $cloudTask->setHttpRequest($httpRequest); + + + $createdTask = CloudTasksApi::createTask( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ), + $cloudTask, + ); + + $secondsSlept = 0; + while ($createdTask->getFirstAttempt() === null) { + $createdTask = CloudTasksApi::getTask($createdTask->getName()); + sleep(1); + $secondsSlept += 1; + + if ($secondsSlept >= 30) { + $this->fail('Task took too long to get executed.'); + } + } + + // The queue max retry duration is 5 seconds. The max retry until timestamp is calculated from the + // first attempt, so we expect it to be [timestamp first attempt] + 5 seconds. + $expected = $createdTask->getFirstAttempt()->getDispatchTime()->getSeconds() + 5; + $actual = CloudTasksApi::getRetryUntilTimestamp($createdTask->getName()); + $this->assertSame($expected, $actual); + } +} From 3cbd18d963a7823b6e13eeb3825b357fcdfd174d Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 14:13:21 +0100 Subject: [PATCH 008/258] Increase timeout --- tests/CloudTasksApiTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/CloudTasksApiTest.php b/tests/CloudTasksApiTest.php index 6d390a5..263b492 100644 --- a/tests/CloudTasksApiTest.php +++ b/tests/CloudTasksApiTest.php @@ -161,7 +161,6 @@ public function test_get_retry_until_timestamp() $cloudTask = new Task(); $cloudTask->setHttpRequest($httpRequest); - $createdTask = CloudTasksApi::createTask( $this->client->queueName( env('CI_CLOUD_TASKS_PROJECT_ID'), @@ -177,7 +176,7 @@ public function test_get_retry_until_timestamp() sleep(1); $secondsSlept += 1; - if ($secondsSlept >= 30) { + if ($secondsSlept >= 180) { $this->fail('Task took too long to get executed.'); } } From 48d756ba87026c9b369143a46f2a10f01cacd705 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 14:41:29 +0100 Subject: [PATCH 009/258] Run failing task test in separate queue to improve speed --- .github/workflows/run-tests.yml | 40 +++++++++++++-------------------- tests/CloudTasksApiTest.php | 2 +- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 36d1a29..2532d0b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,31 +7,22 @@ on: jobs: php-tests: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - php: [8.1, 8.0, 7.4] - laravel: [9.*, 8.*, 7.*, 6.*] - os: [ubuntu-latest] - include: - - laravel: 9.* - testbench: 7.* - - laravel: 8.* - testbench: 6.* - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* - exclude: - - laravel: 9.* - php: 7.4 - - laravel: 6.* - php: 8.1 - - laravel: 7.* - php: 8.1 - - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + payload: + - { queue: 'github-actions-laravel9-php81', laravel: '9.*', php: '8.1', 'testbench': '7.*'} + - { queue: 'github-actions-laravel9-php80', laravel: '9.*', php: '8.0', 'testbench': '7.*'} + - { queue: 'github-actions-laravel8-php81', laravel: '8.*', php: '8.1', 'testbench': '6.*'} + - { queue: 'github-actions-laravel8-php80', laravel: '8.*', php: '8.0', 'testbench': '6.*'} + - { queue: 'github-actions-laravel8-php74', laravel: '8.*', php: '7.4', 'testbench': '6.*'} + - { queue: 'github-actions-laravel7-php80', laravel: '7.*', php: '8.0', 'testbench': '5.*' } + - { queue: 'github-actions-laravel7-php74', laravel: '7.*', php: '7.4', 'testbench': '5.*' } + - { queue: 'github-actions-laravel6-php80', laravel: '6.*', php: '8.0', 'testbench': '4.*' } + - { queue: 'github-actions-laravel6-php74', laravel: '6.*', php: '7.4', 'testbench': '4.*' } + + name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} steps: - name: Checkout code @@ -40,7 +31,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: ${{ matrix.payload.php }} extensions: mbstring, dom, fileinfo coverage: none @@ -51,7 +42,7 @@ jobs: - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction --no-suggest - name: Execute tests env: @@ -66,6 +57,7 @@ jobs: CI_CLOUD_TASKS_LOCATION: ${{ secrets.CI_CLOUD_TASKS_LOCATION }} CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL: ${{ secrets.CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL }} CI_SERVICE_ACCOUNT_JSON_KEY: ${{ secrets.CI_SERVICE_ACCOUNT_JSON_KEY }} + CI_CLOUD_TASKS_CUSTOM_QUEUE: ${{ matrix.payload.queue }} run: | echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json vendor/bin/phpunit diff --git a/tests/CloudTasksApiTest.php b/tests/CloudTasksApiTest.php index 263b492..0c878c9 100644 --- a/tests/CloudTasksApiTest.php +++ b/tests/CloudTasksApiTest.php @@ -165,7 +165,7 @@ public function test_get_retry_until_timestamp() $this->client->queueName( env('CI_CLOUD_TASKS_PROJECT_ID'), env('CI_CLOUD_TASKS_LOCATION'), - env('CI_CLOUD_TASKS_QUEUE') + env('CI_CLOUD_TASKS_CUSTOM_QUEUE', env('CI_CLOUD_TASKS_QUEUE')) ), $cloudTask, ); From 72f4958da9410d192e5a88d329efd32a013687cf Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 16:31:49 +0100 Subject: [PATCH 010/258] Test package with PostgreSQL --- .github/workflows/run-tests.yml | 24 ++++++----- docker-compose.yml | 13 +++--- phpunit.xml | 7 +-- setup-test-env.php | 76 --------------------------------- tests/CloudTasksApiTest.php | 2 +- tests/TaskHandlerTest.php | 2 +- tests/TestCase.php | 13 +++--- 7 files changed, 31 insertions(+), 106 deletions(-) delete mode 100644 setup-test-env.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2532d0b..2869804 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: + db: ['mysql', 'pgsql'] payload: - { queue: 'github-actions-laravel9-php81', laravel: '9.*', php: '8.1', 'testbench': '7.*'} - { queue: 'github-actions-laravel9-php80', laravel: '9.*', php: '8.0', 'testbench': '7.*'} @@ -22,7 +23,7 @@ jobs: - { queue: 'github-actions-laravel6-php80', laravel: '6.*', php: '8.0', 'testbench': '4.*' } - { queue: 'github-actions-laravel6-php74', laravel: '6.*', php: '7.4', 'testbench': '4.*' } - name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} + name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} - DB ${{ matrix.db }} steps: - name: Checkout code @@ -35,23 +36,24 @@ jobs: extensions: mbstring, dom, fileinfo coverage: none - - name: Set up MySQL + - name: Set up MySQL and PostgreSQL run: | - sudo /etc/init.d/mysql start - mysql -e 'CREATE DATABASE test;' -uroot -proot - + MYSQL_PORT=3307 POSTGRES_PORT=5432 docker compose up ${{ matrix.db }} -d - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction --no-suggest + if [ "${{ matrix.db }}" = "mysql" ]; then + while ! mysqladmin ping --host=127.0.0.1 --user=cloudtasks --port=3307 --password=cloudtasks --silent; do + echo "Waiting for MySQL..." + sleep 1 + done + else + echo "Not waiting for MySQL." + fi - name: Execute tests env: - CI_DB_DRIVER: mysql - CI_DB_HOST: 127.0.0.1 - CI_DB_PORT: 3306 - CI_DB_DATABASE: test - CI_DB_USERNAME: root - CI_DB_PASSWORD: root + DB_DRIVER: ${{ matrix.db }} CI_CLOUD_TASKS_PROJECT_ID: ${{ secrets.CI_CLOUD_TASKS_PROJECT_ID }} CI_CLOUD_TASKS_QUEUE: ${{ secrets.CI_CLOUD_TASKS_QUEUE }} CI_CLOUD_TASKS_LOCATION: ${{ secrets.CI_CLOUD_TASKS_LOCATION }} diff --git a/docker-compose.yml b/docker-compose.yml index 9ee716b..774c616 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,17 @@ services: mysql: image: mysql:8 ports: - - 3306:3306 + - '${MYSQL_PORT:-3307}:3306' environment: - MYSQL_ROOT_PASSWORD: 'my-secret-pw' + MYSQL_USER: 'cloudtasks' + MYSQL_PASSWORD: 'cloudtasks' MYSQL_DATABASE: 'cloudtasks' - postgres: + MYSQL_ROOT_PASSWORD: 'root' + pgsql: image: postgres:14 ports: - - 5432:5432 + - '${POSTGRES_PORT:-5432}:5432' environment: - POSTGRES_PASSWORD: 'my-secret-pw' + POSTGRES_USER: 'cloudtasks' + POSTGRES_PASSWORD: 'cloudtasks' POSTGRES_DB: 'cloudtasks' diff --git a/phpunit.xml b/phpunit.xml index 3f7717a..204449c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,11 +24,6 @@ - - - - - - + diff --git a/setup-test-env.php b/setup-test-env.php deleted file mode 100644 index 453b0cd..0000000 --- a/setup-test-env.php +++ /dev/null @@ -1,76 +0,0 @@ - 'path', - 'url' => '../../', - ], - ]; - } - - $encoded = json_encode($toArray, JSON_PRETTY_PRINT); - - file_put_contents('./tests/laravel/composer.json', $encoded); - - $envs = [ - 'APP_ENV=local' => 'APP_ENV=testing', - 'DB_DATABASE=laravel' => 'DB_DATABASE=cloudtasks', - "DB_PASSWORD=\n" => "DB_PASSWORD=my-secret-pw\n", - 'QUEUE_CONNECTION=sync' => 'QUEUE_CONNECTION=cloudtasks', - ]; - - file_put_contents( - './tests/laravel/.env', - str_replace( - array_keys($envs), - array_values($envs), - file_get_contents('./tests/laravel/.env') - ) - ); - - // Prepare the config/queue.php file. - function env() { - // - } - $queue = include('./tests/laravel/config/queue.php'); - - if (!isset($queue['connections']['cloudtasks'])) { - $queue['default'] = 'cloudtasks'; - $queue['connections']['cloudtasks'] = [ - 'driver' => 'cloudtasks', - 'project' => 'my-test-project', - 'queue' => 'barbequeue', - 'location' => 'europe-west6', - 'handler' => 'http://docker.for.mac.localhost:8080/handle-task', - 'service_account_email' => 'info@stackkit.io', - ]; - $queue['failed']['driver'] = 'database-uuids'; - file_put_contents('./tests/laravel/config/queue.php', 'assertMatchesRegularExpression( - '/projects\/' . env('CI_CLOUD_TASKS_PROJECT_ID') . '\/locations\/' . env('CI_CLOUD_TASKS_LOCATION') . '\/queues\/' . env('CI_CLOUD_TASKS_QUEUE') . '\/tasks\/\d{19,}$/', + '/projects\/' . env('CI_CLOUD_TASKS_PROJECT_ID') . '\/locations\/' . env('CI_CLOUD_TASKS_LOCATION') . '\/queues\/' . env('CI_CLOUD_TASKS_QUEUE') . '\/tasks\/\d+$/', $taskName ); } diff --git a/tests/TaskHandlerTest.php b/tests/TaskHandlerTest.php index 29e7afb..b70331f 100644 --- a/tests/TaskHandlerTest.php +++ b/tests/TaskHandlerTest.php @@ -241,7 +241,7 @@ public function test_max_attempts_in_combination_with_retry_until() ->setMaxAttempts(3) ->setMaxRetryDuration(new Duration(['seconds' => 3])) ); - CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 1)->byDefault(); + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 10)->byDefault(); $job = $this->dispatch(new FailingJob()); diff --git a/tests/TestCase.php b/tests/TestCase.php index fc819d5..f0f1370 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -66,13 +66,14 @@ protected function getEnvironmentSetUp($app) } $app['config']->set('database.default', 'testbench'); + $port = env('DB_DRIVER') === 'mysql' ? 3307 : 5432; $app['config']->set('database.connections.testbench', [ - 'driver' => 'mysql', - 'host' => env('CI_DB_HOST', env('DB_HOST')), - 'port' => env('CI_DB_PORT', env('DB_PORT')), - 'database' => env('CI_DB_DATABASE', env('DB_DATABASE')), - 'username' => env('CI_DB_USERNAME', env('DB_USERNAME')), - 'password' => env('CI_DB_PASSWORD', env('DB_PASSWORD')), + 'driver' => env('DB_DRIVER', 'mysql'), + 'host' => '127.0.0.1', + 'port' => $port, + 'database' => 'cloudtasks', + 'username' => 'cloudtasks', + 'password' => 'cloudtasks', 'prefix' => '', ]); From fba70791b63c10c80111348de9034885e257b88d Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 20 Feb 2022 17:47:11 +0100 Subject: [PATCH 011/258] Clean up composer.json --- composer.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index b3d7096..ab7b76b 100644 --- a/composer.json +++ b/composer.json @@ -9,13 +9,11 @@ ], "require": { "ext-json": "*", - "google/cloud-tasks": "^1.6", - "firebase/php-jwt": "^5.2", - "phpseclib/phpseclib": "~2.0" + "phpseclib/phpseclib": "~2.0", + "google/cloud-tasks": "^1.10" }, "require-dev": { - "mockery/mockery": "^1.2", - "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0 || ^5.0" + "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, "autoload": { "psr-4": { From 34b18034d14fecef919ac8b4853e08da557a4e7c Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Mon, 21 Feb 2022 18:29:03 +0100 Subject: [PATCH 012/258] Add Larastan and fix its issues --- composer.json | 10 ++- factories/StackkitCloudTaskFactory.php | 26 ++++++++ phpstan.neon | 10 +++ src/Authenticate.php | 11 +++- src/CloudTasks.php | 8 +-- src/CloudTasksApiConcrete.php | 21 ++++-- src/CloudTasksApiController.php | 50 +++++++------- src/CloudTasksJob.php | 39 +++++------ src/CloudTasksQueue.php | 85 ++++++++++++++++++------ src/CloudTasksServiceProvider.php | 36 +++++------ src/Config.php | 2 +- src/Entities/StatRow.php | 21 ++++++ src/Errors.php | 8 +-- src/LogFake.php | 7 +- src/MonitoringService.php | 90 ++++++++++++++------------ src/StackkitCloudTask.php | 83 +++++++++++++++--------- src/TaskHandler.php | 50 ++++++++------ src/TaskMetadata.php | 16 ++--- tests/CloudTasksMonitoringTest.php | 23 +++++++ 19 files changed, 398 insertions(+), 198 deletions(-) create mode 100644 factories/StackkitCloudTaskFactory.php create mode 100644 phpstan.neon create mode 100644 src/Entities/StatRow.php create mode 100644 tests/CloudTasksMonitoringTest.php diff --git a/composer.json b/composer.json index ab7b76b..c899fad 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,13 @@ "require": { "ext-json": "*", "phpseclib/phpseclib": "~2.0", - "google/cloud-tasks": "^1.10" + "google/cloud-tasks": "^1.10", + "thecodingmachine/safe": "^2.1" }, "require-dev": { - "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "nunomaduro/larastan": "^1.0 || ^2.0", + "thecodingmachine/phpstan-safe-rule": "^1.2" }, "autoload": { "psr-4": { @@ -22,7 +25,8 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Factories\\": "factories/" } }, "extra": { diff --git a/factories/StackkitCloudTaskFactory.php b/factories/StackkitCloudTaskFactory.php new file mode 100644 index 0000000..b3bf9b4 --- /dev/null +++ b/factories/StackkitCloudTaskFactory.php @@ -0,0 +1,26 @@ + 'pending', + 'queue' => 'barbequeue', + 'task_uuid' => (string) Str::uuid(), + 'name' => 'SimpleJob', + 'metadata' => '{}', + 'payload' => '{}', + ]; + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b2e12de --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon +parameters: + paths: + - src + level: 9 + checkMissingIterableValueType: false + ignoreErrors: + - '/Cannot call method when\(\) on mixed/' diff --git a/src/Authenticate.php b/src/Authenticate.php index 75ad81a..c463970 100644 --- a/src/Authenticate.php +++ b/src/Authenticate.php @@ -2,10 +2,17 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Closure; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; + class Authenticate { - public function handle($request, $next) + /** + * @return Response|never + */ + public function handle(Request $request, Closure $next) { return CloudTasks::check($request) ? $next($request) : abort(403); } -} \ No newline at end of file +} diff --git a/src/CloudTasks.php b/src/CloudTasks.php index df1a942..fe58534 100644 --- a/src/CloudTasks.php +++ b/src/CloudTasks.php @@ -4,7 +4,7 @@ use Closure; -class CloudTasks +final class CloudTasks { /** * The callback that should be used to authenticate Cloud Tasks users. @@ -34,8 +34,6 @@ public static function auth(Closure $callback) */ public static function check($request) { - return (static::$authUsing ?: function () { - return app()->environment('local'); - })($request); + return (static::$authUsing)($request); } -} \ No newline at end of file +} diff --git a/src/CloudTasksApiConcrete.php b/src/CloudTasksApiConcrete.php index 7c9e9a0..1488074 100644 --- a/src/CloudTasksApiConcrete.php +++ b/src/CloudTasksApiConcrete.php @@ -4,10 +4,13 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Exception; use Google\Cloud\Tasks\V2\Attempt; use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; +use Google\Protobuf\Duration; +use Google\Protobuf\Timestamp; class CloudTasksApiConcrete implements CloudTasksApiContract { @@ -23,7 +26,13 @@ public function __construct(CloudTasksClient $client) public function getRetryConfig(string $queueName): RetryConfig { - return $this->client->getQueue($queueName)->getRetryConfig(); + $retryConfig = $this->client->getQueue($queueName)->getRetryConfig(); + + if (! $retryConfig instanceof RetryConfig) { + throw new Exception('Queue does not have a retry config.'); + } + + return $retryConfig; } public function createTask(string $queueName, Task $task): Task @@ -41,7 +50,6 @@ public function getTask(string $taskName): Task return $this->client->getTask($taskName); } - public function getRetryUntilTimestamp(string $taskName): ?int { $task = $this->getTask($taskName); @@ -56,13 +64,16 @@ public function getRetryUntilTimestamp(string $taskName): ?int $retryConfig = $this->getRetryConfig($queueName); - if (! $retryConfig->hasMaxRetryDuration()) { + $maxRetryDuration = $retryConfig->getMaxRetryDuration(); + $dispatchTime = $attempt->getDispatchTime(); + + if (! $maxRetryDuration instanceof Duration || ! $dispatchTime instanceof Timestamp) { return null; } - $maxDurationInSeconds = $retryConfig->getMaxRetryDuration()->getSeconds(); + $maxDurationInSeconds = (int) $maxRetryDuration->getSeconds(); - $firstAttemptTimestamp = $attempt->getDispatchTime()->toDateTime()->getTimestamp(); + $firstAttemptTimestamp = $dispatchTime->toDateTime()->getTimestamp(); return $firstAttemptTimestamp + $maxDurationInSeconds; } diff --git a/src/CloudTasksApiController.php b/src/CloudTasksApiController.php index 5ea44a4..2e307a2 100644 --- a/src/CloudTasksApiController.php +++ b/src/CloudTasksApiController.php @@ -5,12 +5,14 @@ use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Stackkit\LaravelGoogleCloudTasksQueue\Entities\StatRow; use const STR_PAD_LEFT; class CloudTasksApiController { - public function dashboard() + public function dashboard(): array { $dbDriver = config('database.connections.' . config('database.default') . '.driver'); @@ -29,6 +31,9 @@ public function dashboard() ], ][$dbDriver]; + /** + * @var array $stats + */ $stats = DB::table((new StackkitCloudTask())->getTable()) ->where('created_at', '>=', now()->utc()->startOfDay()) ->select( @@ -52,6 +57,7 @@ public function dashboard() ] ) ->get() + ->map(fn($row) => StatRow::createFromObject($row)) ->toArray(); $response = [ @@ -98,6 +104,9 @@ public function dashboard() return $response; } + /** + * @return Collection + */ public function tasks() { Carbon::setTestNowAndTimezone(now()->utc()); @@ -112,12 +121,12 @@ public function tasks() [$hour, $minute] = explode(':', request('time')); return $builder - ->where('created_at', '>=', now()->setTime($hour, $minute, 0)) - ->where('created_at', '<=', now()->setTime($hour, $minute, 59)); + ->where('created_at', '>=', now()->setTime((int) $hour, (int) $minute, 0)) + ->where('created_at', '<=', now()->setTime((int) $hour, (int) $minute, 59)); }) ->when(request('hour'), function (Builder $builder, $hour) { - return $builder->where('created_at', '>=', now()->setTime($hour, 0, 0)) - ->where('created_at', '<=', now()->setTime($hour, 59, 59)); + return $builder->where('created_at', '>=', now()->setTime((int) $hour, 0, 0)) + ->where('created_at', '<=', now()->setTime((int) $hour, 59, 59)); }) ->when(request('queue'), function (Builder $builder, $queue) { return $builder->where('queue', $queue); @@ -130,26 +139,23 @@ public function tasks() $maxId = $tasks->max('id'); - return $tasks->map(function (StackkitCloudTask $task) use ($tasks, $maxId) + return $tasks->map(function (StackkitCloudTask $task) use ($maxId) { - return [ - 'uuid' => $task->task_uuid, - 'id' => str_pad($task->id, strlen($maxId), 0, STR_PAD_LEFT), - 'name' => $task->name, - 'status' => $task->status, - 'attempts' => $task->getNumberOfAttempts(), - 'created' => $task->created_at->diffForHumans(), - 'queue' => $task->queue, - ]; - }); + return [ + 'uuid' => $task->task_uuid, + 'id' => str_pad((string) $task->id, strlen($maxId), '0', STR_PAD_LEFT), + 'name' => $task->name, + 'status' => $task->status, + 'attempts' => $task->getNumberOfAttempts(), + 'created' => $task->created_at ? $task->created_at->diffForHumans() : null, + 'queue' => $task->queue, + ]; + }); } - public function task($uuid) + public function task(string $uuid): array { - /** - * @var StackkitCloudTask $task - */ - $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + $task = StackkitCloudTask::findByUuid($uuid); return [ 'id' => $task->id, @@ -160,4 +166,4 @@ public function task($uuid) 'exception' => $task->getMetadata()['exception'] ?? null, ]; } -} \ No newline at end of file +} diff --git a/src/CloudTasksJob.php b/src/CloudTasksJob.php index 97aa511..c9f4e5a 100644 --- a/src/CloudTasksJob.php +++ b/src/CloudTasksJob.php @@ -2,33 +2,34 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\CloudTasksClient; use Illuminate\Container\Container; use Illuminate\Queue\Jobs\Job as LaravelJob; use Illuminate\Contracts\Queue\Job as JobContract; +use function Safe\json_encode; class CloudTasksJob extends LaravelJob implements JobContract { - private $job; - private $attempts; - private $maxTries; - public $retryUntil = null; + private array $job; + private ?int $attempts; + private ?int $maxTries; + public ?int $retryUntil = null; /** * @var CloudTasksQueue */ public $cloudTasksQueue; - public function __construct($job, CloudTasksQueue $cloudTasksQueue) + public function __construct(array $job, CloudTasksQueue $cloudTasksQueue) { $this->job = $job; $this->container = Container::getInstance(); $this->cloudTasksQueue = $cloudTasksQueue; + /** @var \stdClass $command */ $command = unserialize($job['data']['command']); $this->queue = $command->queue; } - public function getJobId() + public function getJobId(): string { return $this->job['uuid']; } @@ -38,64 +39,64 @@ public function uuid(): string return $this->job['uuid']; } - public function getRawBody() + public function getRawBody(): string { return json_encode($this->job); } - public function attempts() + public function attempts(): ?int { return $this->attempts; } - public function setAttempts($attempts) + public function setAttempts(int $attempts): void { $this->attempts = $attempts; } - public function setMaxTries($maxTries) + public function setMaxTries(int $maxTries): void { - if ((int) $maxTries === -1) { + if ($maxTries === -1) { $maxTries = 0; } $this->maxTries = $maxTries; } - public function maxTries() + public function maxTries(): ?int { return $this->maxTries; } - public function setQueue($queue) + public function setQueue(string $queue): void { $this->queue = $queue; } - public function setRetryUntil($retryUntil) + public function setRetryUntil(?int $retryUntil): void { $this->retryUntil = $retryUntil; } - public function retryUntil() + public function retryUntil(): ?int { return $this->retryUntil; } // timeoutAt was renamed to retryUntil in 8.x but we still support this. - public function timeoutAt() + public function timeoutAt(): ?int { return $this->retryUntil; } - public function delete() + public function delete(): void { parent::delete(); $this->cloudTasksQueue->delete($this); } - public function fire() + public function fire(): void { $this->attempts++; diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index c8b50be..0176c91 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -12,47 +12,91 @@ use Illuminate\Queue\Queue as LaravelQueue; use Illuminate\Support\InteractsWithTime; use Illuminate\Support\Str; +use function Safe\json_encode; +use function Safe\json_decode; class CloudTasksQueue extends LaravelQueue implements QueueContract { use InteractsWithTime; + /** + * @var CloudTasksClient + */ private $client; - private $default; - public $config; + + public array $config; public function __construct(array $config, CloudTasksClient $client) { $this->client = $client; - $this->default = $config['queue']; $this->config = $config; } + /** + * Get the size of the queue. + * + * @param string|null $queue + * @return int + */ public function size($queue = null) { - // TODO: Implement size() method. + // It is not possible to know the number of tasks in the queue. + return 0; } + /** + * Push a new job onto the queue. + * + * @param string|object $job + * @param mixed $data + * @param string|null $queue + * @return void + */ public function push($job, $data = '', $queue = null) { - return $this->pushToCloudTasks($queue, $this->createPayload( + $this->pushToCloudTasks($queue, $this->createPayload( $job, $this->getQueue($queue), $data )); } + /** + * Push a raw payload onto the queue. + * + * @param string $payload + * @param string|null $queue + * @param array $options + * @return void + */ public function pushRaw($payload, $queue = null, array $options = []) { - return $this->pushToCloudTasks($queue, $payload); + $this->pushToCloudTasks($queue, $payload); } + /** + * Push a new job onto the queue after a delay. + * + * @param \DateTimeInterface|\DateInterval|int $delay + * @param string|object $job + * @param mixed $data + * @param string|null $queue + * @return void + */ public function later($delay, $job, $data = '', $queue = null) { - return $this->pushToCloudTasks($queue, $this->createPayload( + $this->pushToCloudTasks($queue, $this->createPayload( $job, $this->getQueue($queue), $data ), $delay); } - protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) + /** + * Push a job to Cloud Tasks. + * + * @param string|null $queue + * @param string $payload + * @param \DateTimeInterface|\DateInterval|int $delay + * @return void + */ + protected function pushToCloudTasks($queue, $payload, $delay = 0) { $queue = $this->getQueue($queue); $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); @@ -88,6 +132,7 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) private function withUuid(string $payload): string { + /** @var array $decoded */ $decoded = json_decode($payload, true); if (!isset($decoded['uuid'])) { @@ -97,25 +142,28 @@ private function withUuid(string $payload): string return json_encode($decoded); } + /** + * Pop the next job off of the queue. + * + * @param string|null $queue + * @return \Illuminate\Contracts\Queue\Job|null + */ public function pop($queue = null) { // TODO: Implement pop() method. } - private function getQueue($queue = null) + private function getQueue(?string $queue = null): string { - return $queue ?: $this->default; + return $queue ?: $this->config['queue']; } - /** - * @return HttpRequest - */ - private function createHttpRequest() + private function createHttpRequest(): HttpRequest { return app(HttpRequest::class); } - public function delete(CloudTasksJob $job) + public function delete(CloudTasksJob $job): void { $config = $this->config; @@ -125,16 +173,13 @@ public function delete(CloudTasksJob $job) $config['project'], $config['location'], $queue, - request()->header('X-Cloudtasks-Taskname') + (string) request()->headers->get('X-Cloudtasks-Taskname') ); CloudTasksApi::deleteTask($taskName); } - /** - * @return Task - */ - private function createTask() + private function createTask(): Task { return app(Task::class); } diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index 5a43718..c73b16b 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -12,10 +12,12 @@ use Illuminate\Routing\Router; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; +use function Safe\file_get_contents; +use function Safe\json_decode; class CloudTasksServiceProvider extends LaravelServiceProvider { - public function boot(QueueManager $queue, Router $router) + public function boot(QueueManager $queue, Router $router): void { $this->authorization(); @@ -38,7 +40,7 @@ protected function authorization() $this->gate(); CloudTasks::auth(function ($request) { - return app()->environment('local') || + return app()->environment('local', 'testing') || Gate::check('viewCloudTasks', [$request->user()]); }); } @@ -59,7 +61,7 @@ protected function gate() }); } - private function registerClient() + private function registerClient(): void { $this->app->singleton(CloudTasksClient::class, function () { return new CloudTasksClient(); @@ -69,33 +71,33 @@ private function registerClient() $this->app->bind('cloud-tasks-api', CloudTasksApiConcrete::class); } - private function registerConnector(QueueManager $queue) + private function registerConnector(QueueManager $queue): void { $queue->addConnector('cloudtasks', function () { return new CloudTasksConnector; }); } - private function registerViews() + private function registerViews(): void { $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); } - private function registerAssets() + private function registerAssets(): void { $this->publishes([ __DIR__ . '/../dashboard/dist' => public_path('vendor/cloud-tasks'), ], ['cloud-tasks-assets']); } - private function registerMigrations() + private function registerMigrations(): void { $this->loadMigrationsFrom([ __DIR__ . '/../migrations', ]); } - private function registerRoutes(Router $router) + private function registerRoutes(Router $router): void { $router->post('handle-task', [TaskHandler::class, 'handle']); @@ -121,24 +123,22 @@ private function registerRoutes(Router $router) }); } - private function registerMonitoring() + private function registerMonitoring(): void { app('events')->listen(JobProcessing::class, function (JobProcessing $event) { - MonitoringService::make()->markAsRunning( - $event->job->uuid() - ); + if ($event->job instanceof CloudTasksJob) { + MonitoringService::make()->markAsRunning($event->job->uuid()); + } }); app('events')->listen(JobProcessed::class, function (JobProcessed $event) { - MonitoringService::make()->markAsSuccessful( - $event->job->uuid() - ); + if ($event->job instanceof CloudTasksJob) { + MonitoringService::make()->markAsSuccessful($event->job->uuid()); + } }); app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { - MonitoringService::make()->markAsError( - $event - ); + MonitoringService::make()->markAsError($event); }); app('events')->listen(JobFailed::class, function ($event) { diff --git a/src/Config.php b/src/Config.php index ab82603..1bd6cd3 100644 --- a/src/Config.php +++ b/src/Config.php @@ -6,7 +6,7 @@ class Config { - public static function validate(array $config) + public static function validate(array $config): void { if (empty($config['project'])) { throw new Error(Errors::invalidProject()); diff --git a/src/Entities/StatRow.php b/src/Entities/StatRow.php new file mode 100644 index 0000000..a92d18a --- /dev/null +++ b/src/Entities/StatRow.php @@ -0,0 +1,21 @@ + $value) { + $object->{$key} = $value; + } + + return $object; + } +} diff --git a/src/Errors.php b/src/Errors.php index c34a345..92b5dd0 100644 --- a/src/Errors.php +++ b/src/Errors.php @@ -4,22 +4,22 @@ class Errors { - public static function invalidProject() + public static function invalidProject(): string { return 'Google Cloud project not provided. To fix this, set the STACKKIT_CLOUD_TASKS_PROJECT environment variable'; } - public static function invalidLocation() + public static function invalidLocation(): string { return 'Google Cloud Tasks location not provided. To fix this, set the STACKKIT_CLOUD_TASKS_LOCATION environment variable'; } - public static function invalidHandler() + public static function invalidHandler(): string { return 'Google Cloud Tasks handler not provided. To fix this, set the STACKKIT_CLOUD_TASKS_HANDLER environment variable'; } - public static function invalidServiceAccountEmail() + public static function invalidServiceAccountEmail(): string { return 'Google Service Account email address not provided. This is needed to secure the handler so it is only accessible by Google. To fix this, set the STACKKIT_CLOUD_TASKS_SERVICE_EMAIL environment variable'; } diff --git a/src/LogFake.php b/src/LogFake.php index 764e1a7..d9a32c6 100644 --- a/src/LogFake.php +++ b/src/LogFake.php @@ -51,17 +51,20 @@ public function debug(string $message, array $context = []): void $this->loggedMessages[] = $message; } + /** + * @param string $level + */ public function log($level, string $message, array $context = []): void { $this->loggedMessages[] = $message; } - public function channel() + public function channel(): self { return $this; } - public function assertLogged(string $message) + public function assertLogged(string $message): void { PHPUnit::assertTrue(in_array($message, $this->loggedMessages), 'The message [' . $message . '] was not logged.'); } diff --git a/src/MonitoringService.php b/src/MonitoringService.php index dc944ef..8c67e15 100644 --- a/src/MonitoringService.php +++ b/src/MonitoringService.php @@ -2,24 +2,37 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Exception; +use Google\Cloud\Tasks\V2\HttpRequest; use Google\Cloud\Tasks\V2\Task; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobFailed; -use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use function Safe\json_decode; class MonitoringService { - public static function make() + public static function make(): MonitoringService { return new MonitoringService(); } - public function addToMonitor($queue, Task $task) + private function getTaskBody(Task $task): string + { + $httpRequest = $task->getHttpRequest(); + + if (! $httpRequest instanceof HttpRequest) { + throw new Exception('Task does not have a HTTP request.'); + } + + return $httpRequest->getBody(); + } + + public function addToMonitor(string $queue, Task $task): void { $metadata = new TaskMetadata(); - $metadata->payload = $task->getHttpRequest()->getBody(); + $metadata->payload = $this->getTaskBody($task); $metadata->addEvent('queued', [ 'queue' => $queue, ]); @@ -29,7 +42,7 @@ public function addToMonitor($queue, Task $task) 'task_uuid' => $this->getTaskUuid($task), 'name' => $this->getTaskName($task), 'queue' => $queue, - 'payload' => $task->getHttpRequest()->getBody(), + 'payload' => $this->getTaskBody($task), 'status' => 'queued', 'metadata' => $metadata->toJson(), 'created_at' => now()->utc(), @@ -37,44 +50,40 @@ public function addToMonitor($queue, Task $task) ]); } - public function markAsRunning($uuid) + public function markAsRunning(string $uuid): void { - $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + $task = StackkitCloudTask::findByUuid($uuid); $task->status = 'running'; - $metadata = $task->getMetadata(); - $events = Arr::get($metadata, 'events', []); - $events[] = [ + $task->addMetadataEvent([ 'status' => $task->status, 'datetime' => now()->utc()->toDateTimeString(), - ]; - $task->setMetadata('events', $events); + ]); $task->save(); } - public function markAsSuccessful($uuid) + public function markAsSuccessful(string $uuid): void { - $task = StackkitCloudTask::whereTaskUuid($uuid)->firstOrFail(); + $task = StackkitCloudTask::findByUuid($uuid); $task->status = 'successful'; - $metadata = $task->getMetadata(); - $events = Arr::get($metadata, 'events', []); - $events[] = [ + $task->addMetadataEvent([ 'status' => $task->status, 'datetime' => now()->utc()->toDateTimeString(), - ]; - $task->setMetadata('events', $events); + ]); $task->save(); } - public function markAsError(JobExceptionOccurred $event) + public function markAsError(JobExceptionOccurred $event): void { - $task = StackkitCloudTask::whereTaskUuid($event->job->uuid()) - ->first(); + /** @var CloudTasksJob $job */ + $job = $event->job; - if (!$task) { + try { + $task = StackkitCloudTask::findByUuid($job->uuid()); + } catch (ModelNotFoundException $e) { return; } @@ -83,43 +92,44 @@ public function markAsError(JobExceptionOccurred $event) } $task->status = 'error'; - $metadata = $task->getMetadata(); - $events = Arr::get($metadata, 'events', []); - $events[] = [ + $task->addMetadataEvent([ 'status' => $task->status, 'datetime' => now()->utc()->toDateTimeString(), - ]; - $task->setMetadata('events', $events); + ]); $task->setMetadata('exception', (string) $event->exception); $task->save(); } - public function markAsFailed(JobFailed $event) + public function markAsFailed(JobFailed $event): void { - $task = StackkitCloudTask::whereTaskUuid($event->job->uuid())->firstOrFail(); + /** @var CloudTasksJob $job */ + $job = $event->job; + + $task = StackkitCloudTask::findByUuid($job->uuid()); $task->status = 'failed'; - $metadata = $task->getMetadata(); - $events = Arr::get($metadata, 'events', []); - $events[] = [ + $task->addMetadataEvent([ 'status' => $task->status, 'datetime' => now()->utc()->toDateTimeString(), - ]; - $task->setMetadata('events', $events); + ]); $task->save(); } - private function getTaskName(Task $task) + private function getTaskName(Task $task): string { - $decode = json_decode($task->getHttpRequest()->getBody(), true); + /** @var array $decode */ + $decode = json_decode($this->getTaskBody($task), true); return $decode['displayName']; } - private function getTaskUuid(Task $task) + private function getTaskUuid(Task $task): string { - return json_decode($task->getHttpRequest()->getBody())->uuid; + /** @var array $task */ + $task = json_decode($this->getTaskBody($task), true); + + return $task['uuid']; } } diff --git a/src/StackkitCloudTask.php b/src/StackkitCloudTask.php index c0a5bc1..5f2866c 100644 --- a/src/StackkitCloudTask.php +++ b/src/StackkitCloudTask.php @@ -2,26 +2,53 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use const JSON_PRETTY_PRINT; - +use function Safe\json_encode; +use function Safe\json_decode; + +/** + * @property int $id + * @property string $queue + * @property string $task_uuid + * @property string $name + * @property string $status + * @property string|null $metadata + * @property string|null $payload + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + */ class StackkitCloudTask extends Model { protected $guarded = []; - public function scopeNewestFirst($builder) + public static function findByUuid(string $uuid): StackkitCloudTask + { + return self::whereTaskUuid($uuid)->firstOrFail(); + } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeNewestFirst(Builder $builder): Builder { return $builder->orderByDesc('created_at'); } - public function scopeFailed($builder) + /** + * @param Builder $builder + * @return Builder + */ + public function scopeFailed(Builder $builder): Builder { return $builder->whereStatus('failed'); } - public function getMetadata() + public function getMetadata(): array { $value = $this->metadata; @@ -29,31 +56,19 @@ public function getMetadata() return []; } - if (is_string($value)) { - $decoded = json_decode($value, true); - - return is_array($decoded) ? $decoded : []; - } + $decoded = json_decode($value, true); - return is_array($value) ? $value : []; + return is_array($decoded) ? $decoded : []; } - /** - * @return int - */ - public function getNumberOfAttempts() + public function getNumberOfAttempts(): int { - $events = Arr::get($this->getMetadata(), 'events', []); - - return count(array_filter($events, function (array $event) { - return in_array( - $event['status'], - ['running'] - ); - })); + return collect($this->getEvents()) + ->where('status', 'running') + ->count(); } - public function setMetadata($key, $value) + public function setMetadata(string $key, mixed $value): void { $metadata = $this->getMetadata(); @@ -62,24 +77,32 @@ public function setMetadata($key, $value) $this->metadata = json_encode($metadata); } - public function incrementAttempts() + public function addMetadataEvent(array $event): void { - // + $metadata = $this->getMetadata(); + + $metadata['events'] ??= []; + + $metadata['events'][] = $event; + + $this->metadata = json_encode($metadata); } - public function getEvents() + public function getEvents(): array { Carbon::setTestNowAndTimezone(now()->utc()); + /** @var array $events */ $events = Arr::get($this->getMetadata(), 'events', []); - return array_map(function (array $event) { + return collect($events)->map(function ($event) { + /** @var array $event */ $event['diff'] = Carbon::parse($event['datetime'])->diffForHumans(); return $event; - }, $events); + })->toArray(); } - public function getPayloadPretty() + public function getPayloadPretty(): string { $payload = $this->getMetadata()['payload'] ?? '[]'; @@ -88,4 +111,4 @@ public function getPayloadPretty() JSON_PRETTY_PRINT ); } -} \ No newline at end of file +} diff --git a/src/TaskHandler.php b/src/TaskHandler.php index e8aeb0c..b326c4f 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -4,12 +4,25 @@ use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; +use Illuminate\Bus\Queueable; +use Illuminate\Queue\Jobs\Job; use Illuminate\Queue\WorkerOptions; +use stdClass; +use UnexpectedValueException; +use function Safe\json_decode; class TaskHandler { + /** + * @var array + */ private $config; + /** + * @var CloudTasksClient + */ + private $client; + /** * @var CloudTasksQueue */ @@ -25,11 +38,7 @@ public function __construct(CloudTasksClient $client) $this->client = $client; } - /** - * @param $task - * @throws CloudTasksException - */ - public function handle($task = null) + public function handle(?array $task = null): void { $task = $task ?: $this->captureTask(); @@ -42,17 +51,20 @@ public function handle($task = null) $this->handleTask($task); } - private function loadQueueConnectionConfiguration($task) + private function loadQueueConnectionConfiguration(array $task): void { + /** + * @var stdClass $command + */ $command = unserialize($task['data']['command']); - $connection = $command->connection ?? config('queue.default'); + $connection = $command->command ?? config('queue.default'); $this->config = array_merge( - config("queue.connections.{$connection}"), + (array) config("queue.connections.{$connection}"), ['connection' => $connection] ); } - private function setQueue() + private function setQueue(): void { $this->queue = new CloudTasksQueue($this->config, $this->client); } @@ -60,7 +72,7 @@ private function setQueue() /** * @throws CloudTasksException */ - private function captureTask() + private function captureTask(): array { $input = (string) (request()->getContent()); @@ -70,18 +82,14 @@ private function captureTask() $task = json_decode($input, true); - if (is_null($task)) { + if (!is_array($task)) { throw new CloudTasksException('Could not decode incoming task'); } return $task; } - /** - * @param $task - * @throws CloudTasksException - */ - private function handleTask($task) + private function handleTask(array $task): void { $job = new CloudTasksJob($task, $this->queue); @@ -94,13 +102,19 @@ private function handleTask($task) // max retry duration has been set. If that duration // has passed, it should stop trying altogether. if ($job->attempts() > 0) { - $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp(request()->header('X-Cloudtasks-Taskname'))); + $taskName = request()->header('X-Cloudtasks-Taskname'); + + if (!is_string($taskName)) { + throw new UnexpectedValueException('Expected task name to be a string.'); + } + + $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp($taskName)); } app('queue.worker')->process($this->config['connection'], $job, new WorkerOptions()); } - private function loadQueueRetryConfig(CloudTasksJob $job) + private function loadQueueRetryConfig(CloudTasksJob $job): void { $queue = $job->getQueue() ?: $this->config['queue']; diff --git a/src/TaskMetadata.php b/src/TaskMetadata.php index 8582818..963bd37 100644 --- a/src/TaskMetadata.php +++ b/src/TaskMetadata.php @@ -2,6 +2,8 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use function Safe\json_encode; + class TaskMetadata { /** @@ -14,11 +16,7 @@ class TaskMetadata */ public $payload; - /** - * @param string $status - * @return void - */ - public function addEvent($status, array $additional = []) + public function addEvent(string $status, array $additional = []): void { $event = [ 'status' => $status, @@ -28,7 +26,7 @@ public function addEvent($status, array $additional = []) $this->events[] = array_merge($additional, $event); } - public function toArray() + public function toArray(): array { return [ 'events' => $this->events, @@ -36,12 +34,12 @@ public function toArray() ]; } - public function toJson() + public function toJson(): string { return json_encode($this->toArray()); } - public static function createFromArray(array $data) + public static function createFromArray(array $data): TaskMetadata { $metadata = new TaskMetadata(); @@ -50,4 +48,4 @@ public static function createFromArray(array $data) return $metadata; } -} \ No newline at end of file +} diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php new file mode 100644 index 0000000..8a6e3ce --- /dev/null +++ b/tests/CloudTasksMonitoringTest.php @@ -0,0 +1,23 @@ +create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/dashboard'); + + // Assert + $response->assertStatus(200); + } +} From 350c28961dd91b30356f7ed4dc8ad691c197e26c Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Wed, 23 Feb 2022 23:02:25 +0100 Subject: [PATCH 013/258] Install codingmachinesafe 1.0 or 2.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c899fad..4042152 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "ext-json": "*", "phpseclib/phpseclib": "~2.0", "google/cloud-tasks": "^1.10", - "thecodingmachine/safe": "^2.1" + "thecodingmachine/safe": "^1.0|^2.0" }, "require-dev": { "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0", From 47ce76e0d053ed4dbeb6acaaf06b4b4daf825b4c Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Wed, 23 Feb 2022 23:09:23 +0100 Subject: [PATCH 014/258] Fix mixed for PHP 7 support --- src/StackkitCloudTask.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/StackkitCloudTask.php b/src/StackkitCloudTask.php index 5f2866c..4af02f1 100644 --- a/src/StackkitCloudTask.php +++ b/src/StackkitCloudTask.php @@ -68,7 +68,10 @@ public function getNumberOfAttempts(): int ->count(); } - public function setMetadata(string $key, mixed $value): void + /** + * @param mixed $value + */ + public function setMetadata(string $key, $value): void { $metadata = $this->getMetadata(); From f7d6e2b0a26eeb01304758d6b180ea2bacf4bcbd Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 26 Feb 2022 14:07:20 +0100 Subject: [PATCH 015/258] Add tests for monitoring bit --- factories/StackkitCloudTaskFactory.php | 2 +- phpunit.xml | 1 + src/CloudTasksServiceProvider.php | 1 - tests/CloudTasksMonitoringTest.php | 386 +++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 2 deletions(-) diff --git a/factories/StackkitCloudTaskFactory.php b/factories/StackkitCloudTaskFactory.php index b3bf9b4..31ca93e 100644 --- a/factories/StackkitCloudTaskFactory.php +++ b/factories/StackkitCloudTaskFactory.php @@ -15,7 +15,7 @@ class StackkitCloudTaskFactory extends Factory public function definition() { return [ - 'status' => 'pending', + 'status' => 'queued', 'queue' => 'barbequeue', 'task_uuid' => (string) Str::uuid(), 'name' => 'SimpleJob', diff --git a/phpunit.xml b/phpunit.xml index 204449c..dd5197f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,6 +13,7 @@ ./tests/ConfigTest.php ./tests/TaskHandlerTest.php ./tests/CloudTasksApiTest.php + ./tests/CloudTasksMonitoringTest.php diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index c73b16b..9dbfc67 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -3,7 +3,6 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; use Google\Cloud\Tasks\V2\CloudTasksClient; -use \Grpc\ChannelCredentials; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobProcessed; diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php index 8a6e3ce..6d26d57 100644 --- a/tests/CloudTasksMonitoringTest.php +++ b/tests/CloudTasksMonitoringTest.php @@ -2,7 +2,14 @@ namespace Tests; +use Carbon\Carbon; use Factories\StackkitCloudTaskFactory; +use Google\Cloud\Tasks\V2\RetryConfig; +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; +use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; +use Stackkit\LaravelGoogleCloudTasksQueue\StackkitCloudTask; +use Tests\Support\FailingJob; +use Tests\Support\SimpleJob; class CloudTasksMonitoringTest extends TestCase { @@ -20,4 +27,383 @@ public function test_loading_dashboard_works() // Assert $response->assertStatus(200); } + + /** + * @test + */ + public function it_counts_the_number_of_tasks() + { + // Arrange + $this->travelTo(Carbon::parse('2022-01-01 15:15:00')); + $lastMinute = now()->startOfMinute()->subMinute(); + $thisMinute = now()->startOfMinute(); + $thisHour = now()->startOfHour(); + $thisDay = now()->startOfDay(); + + StackkitCloudTaskFactory::new() + ->crossJoinSequence( + [['status' => 'failed'], ['status' => 'queued']], + [['created_at' => $thisMinute], ['created_at' => $thisHour], ['created_at' => $thisDay], ['created_at' => $lastMinute]] + ) + ->count(8) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/dashboard'); + + // Assert + $this->assertEquals(2, $response->json('recent.this_minute')); + $this->assertEquals(6, $response->json('recent.this_hour')); + $this->assertEquals(8, $response->json('recent.this_day')); + + $this->assertEquals(1, $response->json('failed.this_minute')); + $this->assertEquals(3, $response->json('failed.this_hour')); + $this->assertEquals(4, $response->json('failed.this_day')); + } + + /** + * @test + */ + public function tasks_shows_newest_first() + { + // Arrange + $tasks = StackkitCloudTaskFactory::new() + ->count(2) + ->sequence( + ['created_at' => now()->subMinute()], + ['created_at' => now()], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks'); + + // Assert + $this->assertEquals($tasks[1]->task_uuid, $response->json('0.uuid')); + } + + /** + * @test + */ + public function it_shows_tasks_only_from_today() + { + // Arrange + $tasks = StackkitCloudTaskFactory::new() + ->count(2) + ->sequence( + ['created_at' => today()], + ['created_at' => today()->subDay()], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks'); + + // Assert + $this->assertCount(1, $response->json()); + } + + /** + * @test + */ + public function it_can_filter_only_failed_tasks() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(2) + ->sequence( + ['status' => 'pending'], + ['status' => 'failed'], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks?filter=failed'); + + // Assert + $this->assertCount(1, $response->json()); + } + + /** + * @test + */ + public function it_can_filter_tasks_created_at_exact_time() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(4) + ->sequence( + ['created_at' => now()->setTime(15,4, 59)], + ['created_at' => now()->setTime(16, 5, 0)], + ['created_at' => now()->setTime(16, 5, 59)], + ['created_at' => now()->setTime(16, 6, 0)], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks?time=16:05'); + + // Assert + $this->assertCount(2, $response->json()); + } + + /** + * @test + */ + public function it_can_filter_tasks_created_at_exact_hour() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(4) + ->sequence( + ['created_at' => now()->setTime(15,59, 59)], + ['created_at' => now()->setTime(16, 5, 59)], + ['created_at' => now()->setTime(16, 32, 32)], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks?hour=16'); + + // Assert + $this->assertCount(2, $response->json()); + } + + /** + * @test + */ + public function it_can_filter_tasks_by_queue() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(3) + ->sequence( + ['queue' => 'barbequeue'], + ['queue' => 'barbequeue-priority'], + ['queue' => 'barbequeue-priority'], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks?queue=barbequeue-priority'); + + // Assert + $this->assertCount(2, $response->json()); + } + + /** + * @test + */ + public function it_can_filter_tasks_by_status() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(4) + ->sequence( + ['status' => 'queued'], + ['status' => 'pending'], + ['status' => 'failed'], + ['status' => 'failed'], + ) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks?status=failed'); + + // Assert + $this->assertCount(2, $response->json()); + } + + /** + * @test + */ + public function it_shows_max_100_tasks() + { + // Arrange + StackkitCloudTaskFactory::new() + ->count(101) + ->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks'); + + // Assert + $this->assertCount(100, $response->json()); + } + + /** + * @test + */ + public function it_returns_the_correct_task_fields() + { + // Arrange + $task = StackkitCloudTaskFactory::new()->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/tasks'); + + // Assert + $this->assertEquals($task->task_uuid, $response->json('0.uuid')); + $this->assertEquals($task->id, $response->json('0.id')); + $this->assertEquals('SimpleJob', $response->json('0.name')); + $this->assertEquals('queued', $response->json('0.status')); + $this->assertEquals(0, $response->json('0.attempts')); + $this->assertEquals('1 second ago', $response->json('0.created')); + $this->assertEquals('barbequeue', $response->json('0.queue')); + } + + /** + * @test + */ + public function it_returns_info_about_a_specific_task() + { + // Arrange + $task = StackkitCloudTaskFactory::new()->create(); + + // Act + $response = $this->getJson('/cloud-tasks-api/task/' . $task->task_uuid); + + // Assert + $this->assertEquals($task->id, $response['id']); + $this->assertEquals('queued', $response['status']); + $this->assertEquals('barbequeue', $response['queue']); + $this->assertEquals([], $response['events']); + $this->assertEquals('[]', $response['payload']); + $this->assertEquals(null, $response['exception']); + } + + /** + * @test + */ + public function when_a_job_is_dispatched_it_will_be_added_to_the_monitor() + { + // Arrange + CloudTasksApi::fake(); + $tasksBefore = StackkitCloudTask::count(); + $job = $this->dispatch(new SimpleJob()); + $tasksAfter = StackkitCloudTask::count(); + + // Assert + $task = StackkitCloudTask::first(); + $this->assertSame(0, $tasksBefore); + $this->assertSame(1, $tasksAfter); + $this->assertDatabaseHas(StackkitCloudTask::class, [ + 'queue' => 'barbequeue', + 'status' => 'queued', + 'name' => SimpleJob::class, + ]); + $payload = \Safe\json_decode($task->getMetadata()['payload'], true); + $this->assertSame($payload, $job->payload); + } + + /** + * @test + */ + public function when_a_job_is_running_it_will_be_updated_in_the_monitor() + { + // Arrange + $this->travelTo(now()); + CloudTasksApi::fake(); + OpenIdVerificator::fake(); + + $this->dispatch(new SimpleJob())->run(); + + // Assert + $task = StackkitCloudTask::firstOrFail(); + $events = $task->getEvents(); + $this->assertCount(3, $events); + $this->assertEquals( + [ + 'status' => 'running', + 'datetime' => now()->toDateTimeString(), + 'diff' => '1 second ago', + ], + $events[1] + ); + } + + /** + * @test + */ + public function when_a_job_is_successful_it_will_be_updated_in_the_monitor() + { + // Arrange + $this->travelTo(now()); + CloudTasksApi::fake(); + OpenIdVerificator::fake(); + + $this->dispatch(new SimpleJob())->run(); + + // Assert + $task = StackkitCloudTask::firstOrFail(); + $events = $task->getEvents(); + $this->assertCount(3, $events); + $this->assertEquals( + [ + 'status' => 'successful', + 'datetime' => now()->toDateTimeString(), + 'diff' => '1 second ago', + ], + $events[2] + ); + } + + /** + * @test + */ + public function when_a_job_errors_it_will_be_updated_in_the_monitor() + { + // Arrange + $this->travelTo(now()); + CloudTasksApi::fake(); + OpenIdVerificator::fake(); + + $this->dispatch(new FailingJob())->run(); + + // Assert + $task = StackkitCloudTask::firstOrFail(); + $events = $task->getEvents(); + $this->assertCount(3, $events); + $this->assertEquals( + [ + 'status' => 'error', + 'datetime' => now()->toDateTimeString(), + 'diff' => '1 second ago', + ], + $events[2] + ); + $this->assertStringContainsString('Error: simulating a failing job', $task->getMetadata()['exception']); + } + + /** + * @test + */ + public function when_a_job_fails_it_will_be_updated_in_the_monitor() + { + // Arrange + $this->travelTo(now()); + CloudTasksApi::fake(); + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); + + $job = $this->dispatch(new FailingJob()); + $job->run(); + $job->run(); + $job->run(); + + // Assert + $task = StackkitCloudTask::firstOrFail(); + $events = $task->getEvents(); + $this->assertCount(7, $events); + $this->assertEquals( + [ + 'status' => 'failed', + 'datetime' => now()->toDateTimeString(), + 'diff' => '1 second ago', + ], + $events[6] + ); + } } From 8c3dc97f204086051126cf36b0bef1b222972dbe Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 26 Feb 2022 14:10:56 +0100 Subject: [PATCH 016/258] Update tests to work with older Laravel versions --- factories/CrossJoinSequence.php | 27 ++++++++++++++++++++++++++ factories/StackkitCloudTaskFactory.php | 11 +++++++++++ tests/CloudTasksMonitoringTest.php | 12 ++++++------ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 factories/CrossJoinSequence.php diff --git a/factories/CrossJoinSequence.php b/factories/CrossJoinSequence.php new file mode 100644 index 0000000..b982337 --- /dev/null +++ b/factories/CrossJoinSequence.php @@ -0,0 +1,27 @@ + '{}', ]; } + + /** + * Add a new cross joined sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function crossJoinSequence(...$sequence) + { + return $this->state(new CrossJoinSequence(...$sequence)); + } } diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php index 6d26d57..e0a9dd6 100644 --- a/tests/CloudTasksMonitoringTest.php +++ b/tests/CloudTasksMonitoringTest.php @@ -2,9 +2,9 @@ namespace Tests; -use Carbon\Carbon; use Factories\StackkitCloudTaskFactory; use Google\Cloud\Tasks\V2\RetryConfig; +use Illuminate\Support\Carbon; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; use Stackkit\LaravelGoogleCloudTasksQueue\StackkitCloudTask; @@ -34,7 +34,7 @@ public function test_loading_dashboard_works() public function it_counts_the_number_of_tasks() { // Arrange - $this->travelTo(Carbon::parse('2022-01-01 15:15:00')); + Carbon::setTestNow(Carbon::parse('2022-01-01 15:15:00')); $lastMinute = now()->startOfMinute()->subMinute(); $thisMinute = now()->startOfMinute(); $thisHour = now()->startOfHour(); @@ -302,7 +302,7 @@ public function when_a_job_is_dispatched_it_will_be_added_to_the_monitor() public function when_a_job_is_running_it_will_be_updated_in_the_monitor() { // Arrange - $this->travelTo(now()); + \Illuminate\Support\Carbon::setTestNow(now()); CloudTasksApi::fake(); OpenIdVerificator::fake(); @@ -328,7 +328,7 @@ public function when_a_job_is_running_it_will_be_updated_in_the_monitor() public function when_a_job_is_successful_it_will_be_updated_in_the_monitor() { // Arrange - $this->travelTo(now()); + \Illuminate\Support\Carbon::setTestNow(now()); CloudTasksApi::fake(); OpenIdVerificator::fake(); @@ -354,7 +354,7 @@ public function when_a_job_is_successful_it_will_be_updated_in_the_monitor() public function when_a_job_errors_it_will_be_updated_in_the_monitor() { // Arrange - $this->travelTo(now()); + \Illuminate\Support\Carbon::setTestNow(now()); CloudTasksApi::fake(); OpenIdVerificator::fake(); @@ -381,7 +381,7 @@ public function when_a_job_errors_it_will_be_updated_in_the_monitor() public function when_a_job_fails_it_will_be_updated_in_the_monitor() { // Arrange - $this->travelTo(now()); + \Illuminate\Support\Carbon::setTestNow(now()); CloudTasksApi::fake(); OpenIdVerificator::fake(); CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( From 62861e83a4476fed7bf2459d15caf12a5b3f0023 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 26 Feb 2022 18:26:22 +0100 Subject: [PATCH 017/258] Switch to old style factories for older Laravel support --- composer.json | 3 +- factories/CrossJoinSequence.php | 27 ------- factories/StackkitCloudTaskFactory.php | 41 +++------- tests/CloudTasksMonitoringTest.php | 106 ++++++++----------------- tests/TestCase.php | 7 ++ 5 files changed, 57 insertions(+), 127 deletions(-) delete mode 100644 factories/CrossJoinSequence.php diff --git a/composer.json b/composer.json index 4042152..c01c313 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "require-dev": { "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0", "nunomaduro/larastan": "^1.0 || ^2.0", - "thecodingmachine/phpstan-safe-rule": "^1.2" + "thecodingmachine/phpstan-safe-rule": "^1.2", + "laravel/legacy-factories": "^1.3" }, "autoload": { "psr-4": { diff --git a/factories/CrossJoinSequence.php b/factories/CrossJoinSequence.php deleted file mode 100644 index b982337..0000000 --- a/factories/CrossJoinSequence.php +++ /dev/null @@ -1,27 +0,0 @@ - 'queued', - 'queue' => 'barbequeue', - 'task_uuid' => (string) Str::uuid(), - 'name' => 'SimpleJob', - 'metadata' => '{}', - 'payload' => '{}', - ]; - } - - /** - * Add a new cross joined sequenced state transformation to the model definition. - * - * @param array $sequence - * @return static - */ - public function crossJoinSequence(...$sequence) - { - return $this->state(new CrossJoinSequence(...$sequence)); - } -} +$factory->define(StackkitCloudTask::class, function (Faker $faker) { + return [ + 'status' => 'queued', + 'queue' => 'barbequeue', + 'task_uuid' => (string) Str::uuid(), + 'name' => 'SimpleJob', + 'metadata' => '{}', + 'payload' => '{}', + ]; +}); diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php index e0a9dd6..4acab62 100644 --- a/tests/CloudTasksMonitoringTest.php +++ b/tests/CloudTasksMonitoringTest.php @@ -2,7 +2,6 @@ namespace Tests; -use Factories\StackkitCloudTaskFactory; use Google\Cloud\Tasks\V2\RetryConfig; use Illuminate\Support\Carbon; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; @@ -19,7 +18,7 @@ class CloudTasksMonitoringTest extends TestCase public function test_loading_dashboard_works() { // Arrange - StackkitCloudTaskFactory::new()->create(); + factory(StackkitCloudTask::class)->create(); // Act $response = $this->getJson('/cloud-tasks-api/dashboard'); @@ -40,13 +39,15 @@ public function it_counts_the_number_of_tasks() $thisHour = now()->startOfHour(); $thisDay = now()->startOfDay(); - StackkitCloudTaskFactory::new() - ->crossJoinSequence( - [['status' => 'failed'], ['status' => 'queued']], - [['created_at' => $thisMinute], ['created_at' => $thisHour], ['created_at' => $thisDay], ['created_at' => $lastMinute]] - ) - ->count(8) - ->create(); + factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisMinute]); + factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisHour]); + factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisDay]); + factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $lastMinute]); + + factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisMinute]); + factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisHour]); + factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisDay]); + factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $lastMinute]); // Act $response = $this->getJson('/cloud-tasks-api/dashboard'); @@ -67,19 +68,14 @@ public function it_counts_the_number_of_tasks() public function tasks_shows_newest_first() { // Arrange - $tasks = StackkitCloudTaskFactory::new() - ->count(2) - ->sequence( - ['created_at' => now()->subMinute()], - ['created_at' => now()], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['created_at' => now()->subMinute()]); + $task = factory(StackkitCloudTask::class)->create(['created_at' => now()]); // Act $response = $this->getJson('/cloud-tasks-api/tasks'); // Assert - $this->assertEquals($tasks[1]->task_uuid, $response->json('0.uuid')); + $this->assertEquals($task->task_uuid, $response->json('0.uuid')); } /** @@ -88,13 +84,8 @@ public function tasks_shows_newest_first() public function it_shows_tasks_only_from_today() { // Arrange - $tasks = StackkitCloudTaskFactory::new() - ->count(2) - ->sequence( - ['created_at' => today()], - ['created_at' => today()->subDay()], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['created_at' => today()]); + factory(StackkitCloudTask::class)->create(['created_at' => today()->subDay()]); // Act $response = $this->getJson('/cloud-tasks-api/tasks'); @@ -109,13 +100,8 @@ public function it_shows_tasks_only_from_today() public function it_can_filter_only_failed_tasks() { // Arrange - StackkitCloudTaskFactory::new() - ->count(2) - ->sequence( - ['status' => 'pending'], - ['status' => 'failed'], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['status' => 'pending']); + factory(StackkitCloudTask::class)->create(['status' => 'failed']); // Act $response = $this->getJson('/cloud-tasks-api/tasks?filter=failed'); @@ -130,15 +116,10 @@ public function it_can_filter_only_failed_tasks() public function it_can_filter_tasks_created_at_exact_time() { // Arrange - StackkitCloudTaskFactory::new() - ->count(4) - ->sequence( - ['created_at' => now()->setTime(15,4, 59)], - ['created_at' => now()->setTime(16, 5, 0)], - ['created_at' => now()->setTime(16, 5, 59)], - ['created_at' => now()->setTime(16, 6, 0)], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(15,4, 59)]); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 0)]); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 59)]); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,6, 0)]); // Act $response = $this->getJson('/cloud-tasks-api/tasks?time=16:05'); @@ -153,14 +134,9 @@ public function it_can_filter_tasks_created_at_exact_time() public function it_can_filter_tasks_created_at_exact_hour() { // Arrange - StackkitCloudTaskFactory::new() - ->count(4) - ->sequence( - ['created_at' => now()->setTime(15,59, 59)], - ['created_at' => now()->setTime(16, 5, 59)], - ['created_at' => now()->setTime(16, 32, 32)], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(15,59, 59)]); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 59)]); + factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,32, 32)]); // Act $response = $this->getJson('/cloud-tasks-api/tasks?hour=16'); @@ -175,14 +151,9 @@ public function it_can_filter_tasks_created_at_exact_hour() public function it_can_filter_tasks_by_queue() { // Arrange - StackkitCloudTaskFactory::new() - ->count(3) - ->sequence( - ['queue' => 'barbequeue'], - ['queue' => 'barbequeue-priority'], - ['queue' => 'barbequeue-priority'], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue']); + factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue-priority']); + factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue-priority']); // Act $response = $this->getJson('/cloud-tasks-api/tasks?queue=barbequeue-priority'); @@ -197,15 +168,10 @@ public function it_can_filter_tasks_by_queue() public function it_can_filter_tasks_by_status() { // Arrange - StackkitCloudTaskFactory::new() - ->count(4) - ->sequence( - ['status' => 'queued'], - ['status' => 'pending'], - ['status' => 'failed'], - ['status' => 'failed'], - ) - ->create(); + factory(StackkitCloudTask::class)->create(['status' => 'queued']); + factory(StackkitCloudTask::class)->create(['status' => 'pending']); + factory(StackkitCloudTask::class)->create(['status' => 'failed']); + factory(StackkitCloudTask::class)->create(['status' => 'failed']); // Act $response = $this->getJson('/cloud-tasks-api/tasks?status=failed'); @@ -220,9 +186,7 @@ public function it_can_filter_tasks_by_status() public function it_shows_max_100_tasks() { // Arrange - StackkitCloudTaskFactory::new() - ->count(101) - ->create(); + factory(StackkitCloudTask::class)->times(101)->create(); // Act $response = $this->getJson('/cloud-tasks-api/tasks'); @@ -237,7 +201,7 @@ public function it_shows_max_100_tasks() public function it_returns_the_correct_task_fields() { // Arrange - $task = StackkitCloudTaskFactory::new()->create(); + $task = factory(StackkitCloudTask::class)->create(); // Act $response = $this->getJson('/cloud-tasks-api/tasks'); @@ -258,7 +222,7 @@ public function it_returns_the_correct_task_fields() public function it_returns_info_about_a_specific_task() { // Arrange - $task = StackkitCloudTaskFactory::new()->create(); + $task = factory(StackkitCloudTask::class)->create(); // Act $response = $this->getJson('/cloud-tasks-api/task/' . $task->task_uuid); @@ -287,7 +251,7 @@ public function when_a_job_is_dispatched_it_will_be_added_to_the_monitor() $task = StackkitCloudTask::first(); $this->assertSame(0, $tasksBefore); $this->assertSame(1, $tasksAfter); - $this->assertDatabaseHas(StackkitCloudTask::class, [ + $this->assertDatabaseHas((new StackkitCloudTask())->getTable(), [ 'queue' => 'barbequeue', 'status' => 'queued', 'name' => SimpleJob::class, diff --git a/tests/TestCase.php b/tests/TestCase.php index f0f1370..dda782d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,13 @@ class TestCase extends \Orchestra\Testbench\TestCase */ public $client; + protected function setUp(): void + { + parent::setUp(); + + $this->withFactories(__DIR__ . '/../factories'); + } + /** * Get package providers. At a minimum this is the package being tested, but also * would include packages upon which our package depends, e.g. Cartalyst/Sentry From 685c72a0fbc6cccb0f57a1765eb7eada454b1ab0 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 26 Feb 2022 19:23:50 +0100 Subject: [PATCH 018/258] WIP --- dashboard/dist/assets/index.481c4ac3.js | 1 - dashboard/dist/assets/index.643ccf47.js | 1 + dashboard/dist/index.html | 2 +- dashboard/dist/manifest.json | 2 +- dashboard/package-lock.json | 1 + dashboard/src/api.js | 11 ++++++++++- dashboard/src/components/Dashboard.vue | 2 +- dashboard/src/components/FilterCard.vue | 2 +- dashboard/src/components/Task.vue | 2 +- src/CloudTasksQueue.php | 3 --- 10 files changed, 17 insertions(+), 10 deletions(-) delete mode 100644 dashboard/dist/assets/index.481c4ac3.js create mode 100644 dashboard/dist/assets/index.643ccf47.js diff --git a/dashboard/dist/assets/index.481c4ac3.js b/dashboard/dist/assets/index.481c4ac3.js deleted file mode 100644 index b19090e..0000000 --- a/dashboard/dist/assets/index.481c4ac3.js +++ /dev/null @@ -1 +0,0 @@ -var B=Object.defineProperty;var L=Object.getOwnPropertySymbols;var D=Object.prototype.hasOwnProperty,E=Object.prototype.propertyIsEnumerable;var U=(o,n,t)=>n in o?B(o,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[n]=t,q=(o,n)=>{for(var t in n||(n={}))D.call(n,t)&&U(o,t,n[t]);if(L)for(var t of L(n))E.call(n,t)&&U(o,t,n[t]);return o};var T=(o,n,t)=>new Promise((r,s)=>{var a=c=>{try{i(t.next(c))}catch(u){s(u)}},l=c=>{try{i(t.throw(c))}catch(u){s(u)}},i=c=>c.done?r(c.value):Promise.resolve(c.value).then(a,l);i((t=t.apply(o,n)).next())});import{r as k,o as d,c as _,a as h,w as f,n as v,F as y,b,d as e,e as x,t as p,f as F,g as O,u as j,h as V,i as H,j as z,k as Q,v as K,l as W,m as G,p as $,q as w,s as M,x as J,y as X,z as Y,A as Z,B as ee,C as te}from"./vendor.f52c9be3.js";const se=function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const a of s)if(a.type==="childList")for(const l of a.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&r(l)}).observe(document,{childList:!0,subtree:!0});function t(s){const a={};return s.integrity&&(a.integrity=s.integrity),s.referrerpolicy&&(a.referrerPolicy=s.referrerpolicy),s.crossorigin==="use-credentials"?a.credentials="include":s.crossorigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(s){if(s.ep)return;s.ep=!0;const a=t(s);fetch(s.href,a)}};se();var C=(o,n)=>{const t=o.__vccOpts||o;for(const[r,s]of n)t[r]=s;return t};const oe={},ne=b("Dashboard "),ae=b("Recent "),le=b("Queued "),re=b("Failed ");function ie(o,n){var r,s,a,l,i,c,u,m,g;const t=k("router-link");return d(),_(y,null,[h(t,{to:{name:"home"},class:"block p-4 rounded mb-2 cursor-pointer"},{default:f(()=>[ne]),_:1}),h(t,{to:{name:"recent"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((a=(s=(r=o.$route)==null?void 0:r.matched[0])==null?void 0:s.meta)==null?void 0:a.route)==="recent"}])},{default:f(()=>[ae]),_:1},8,["class"]),h(t,{to:{name:"queued"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((c=(i=(l=o.$route)==null?void 0:l.matched[0])==null?void 0:i.meta)==null?void 0:c.route)==="queued"}])},{default:f(()=>[le]),_:1},8,["class"]),h(t,{to:{name:"failed"},class:v(["block p-4 rounded mb-2",{"router-link-active":((g=(m=(u=o.$route)==null?void 0:u.matched[0])==null?void 0:m.meta)==null?void 0:g.route)==="failed"}])},{default:f(()=>[re]),_:1},8,["class"])],64)}var ce=C(oe,[["render",ie]]);const ue={components:{Menu:ce}},de={class:"flex"},pe={class:"basis-auto w-[250px] shrink-0 bg-white p-6 min-h-screen"},_e={class:"flex-1 max-w-[calc(100%-250px)] p-6"};function he(o,n,t,r,s,a){const l=k("Menu"),i=k("router-view");return d(),_("div",de,[e("aside",pe,[h(l)]),e("div",_e,[h(i)])])}var me=C(ue,[["render",he]]);const fe=e("h3",{class:"text-3xl mb-4"},"All tasks",-1),xe={class:"grid grid-cols-3 gap-4"},ve=["textContent"],ye=e("span",{class:"text-gray-600"},"this minute",-1),ge=["textContent"],be=e("span",{class:"text-gray-600"},"this hour",-1),we=["textContent"],ke=e("span",{class:"text-gray-600"},"today",-1),$e=e("h3",{class:"text-3xl mb-4 mt-8"},"Failed tasks",-1),Ce={class:"grid grid-cols-3 gap-4"},qe=["textContent"],Te=e("span",{class:"text-gray-600"},"this minute",-1),Se=["textContent"],Ae=e("span",{class:"text-gray-600"},"this hour",-1),Ie=["textContent"],Re=e("span",{class:"text-gray-600"},"today",-1),Le={setup(o){const n=x({recent:{this_minute:"...",this_hour:"...",today:"..."},failed:{this_minute:"...",this_hour:"...",today:"..."}});return fetch("http://github.com/service/http://localhost:8000/cloud-tasks-api/dashboard").then(t=>t.json()).then(t=>n.value=t),(t,r)=>{const s=k("router-link");return d(),_(y,null,[fe,e("div",xe,[h(s,{to:{name:"recent",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_minute)},null,8,ve),ye]}),_:1},8,["to"]),h(s,{to:{name:"recent",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_hour)},null,8,ge),be]}),_:1},8,["to"]),h(s,{to:{name:"recent"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_day)},null,8,we),ke]}),_:1})]),$e,e("div",Ce,[h(s,{to:{name:"failed",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_minute)},null,8,qe),Te]}),_:1},8,["to"]),h(s,{to:{name:"failed",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_hour)},null,8,Se),Ae]}),_:1},8,["to"]),h(s,{to:{name:"failed"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_day)},null,8,Ie),Re]}),_:1})])],64)}}};function S(t){return T(this,arguments,function*(o,n={}){const r=function(l){return T(this,null,function*(){const i=new URL(window.location.href),c=new URLSearchParams(i.search);for(const[u,m]of Object.entries(n))c.append(u,m);fetch(`http://localhost:8000/cloud-tasks-api/tasks?${c.toString()}`).then(u=>u.json()).then(u=>{l.value=u})})};r(o);let s=setInterval(()=>r(o),3e3);F(function(){setTimeout(()=>r(o))});const a=function(){document.visibilityState==="visible"?(r(o),clearInterval(s),s=setInterval(()=>r(o),3e3)):document.visibilityState==="hidden"&&clearInterval(s)};document.addEventListener("visibilitychange",a),O(()=>{clearInterval(s),document.removeEventListener("visibilitychange",a)})})}const N={props:{status:String,classes:{type:Array,default:[]}},setup(o){function n(t){return t.charAt(0).toUpperCase()+t.slice(1)}return(t,r)=>(d(),_("span",{class:v(["px-2 inline-flex text-xs leading-5 font-semibold rounded-full",[`task-${o.status}`,...o.classes]])},p(n(o.status)),3))}},Ue={},Ve=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},[e("svg",{class:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"indigo","stroke-width":"4"}),e("path",{class:"opacity-75",fill:"indigo",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})])])],-1),Me=[Ve];function Ne(o,n){return d(),_("tbody",null,Me)}var Pe=C(Ue,[["render",Ne]]);const Be=e("label",{for:"queue",class:"block mb-2 font-medium"},"Queue",-1),De=["onKeyup"],Ee=e("label",{for:"status",class:"block mb-2 mt-6 font-medium"},"Status",-1),Fe=W('',6),Oe=[Fe],je={props:{focus:String},setup(o){const n=o,t=j(),r=V(),s=x(!1),a=x(null),l=x(null);function i(){t.push({name:r.name,query:q(q({},a.value.value?{queue:a.value.value}:{}),l.value?{status:l.value}:{})})}function c(u){u===""&&i()}return H(()=>{setTimeout(()=>s.value=!0),n.focus==="queue"&&a.value.focus()}),(u,m)=>(d(),_("div",{class:v(["w-[300px] fixed transition-transform right-0 top-0 p-6 px-6 shadow-2xl h-screen bg-white",{"translate-x-[300px]":s.value===!1}])},[Be,e("input",{type:"text",name:"queue",id:"queue",ref_key:"queue",ref:a,class:"bg-white py-2 px-3 w-full rounded border",onKeyup:[z(i,["enter"]),m[0]||(m[0]=g=>c(g.target.value))]},null,40,De),Ee,Q(e("select",{name:"status",id:"status","onUpdate:modelValue":m[1]||(m[1]=g=>l.value=g),class:"bg-white py-2 px-3 w-full rounded border"},Oe,512),[[K,l.value]]),e("button",{class:"bg-indigo-500 w-full mt-4 text-indigo-100 rounded py-2",onClick:i}," Apply Filter (or Press Enter) ")],2))}};const He={class:"text-4xl mb-2"},ze={class:"text-lg"},Qe={class:"flex flex-row mt-6"},Ke={class:"flex-1"},We={class:"align-middle"},Ge={class:"shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"},Je={class:"table-fixed divide-y divide-gray-200 w-full"},Xe={class:"bg-gray-50"},Ye=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[50px]"}," # ",-1),Ze=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider max-w-xl w-[300px]"}," Name ",-1),et=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[100px]"}," Status ",-1),tt=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px] text-center"}," Attempts ",-1),st=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[200px]"}," Created ",-1),ot={scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"},nt=b(" Queue "),at={class:"inline relative"},lt=e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"},null,-1),rt=[lt],it=e("th",{scope:"col",class:"relative px-6 py-3"},[e("span",{class:"sr-only"},"Edit")],-1),ct={key:1},ut=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},"No results.")],-1),dt=[ut],pt={class:"bg-white divide-y divide-gray-200"},_t=["onClick"],ht={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-900"},mt={class:"px-6 py-4 whitespace-nowrap text-ellipsis text-sm text-gray-900"},ft={class:"px-6 py-4 whitespace-nowrap"},xt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center"},vt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},yt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},gt=e("td",{class:"px-6 py-4 whitespace-nowrap text-right text-sm font-medium"},[e("a",{href:"#",class:"text-indigo-600 hover:text-indigo-900"},"View")],-1),A={props:{title:String,description:String,tasks:Array},setup(o){const n=o,t=x([]),r=x([]),s=x({visible:!1,focus:null});function a(l){t.value.push(l.id),setTimeout(()=>{t.value.splice(t.value.indexOf(l.id),1)},1e3)}return G(()=>n.tasks,(l,i)=>{var c;if(!!i){r.value=[],i.map((u,m)=>{r[u.id]=m});for(const u of l)(r[u.id]===void 0||((c=i[r[u.id]])==null?void 0:c.status)!==u.status)&&a(u)}}),(l,i)=>(d(),_(y,null,[e("h1",He,p(o.title),1),e("p",ze,p(o.description),1),e("div",Qe,[e("div",Ke,[e("div",We,[e("div",Ge,[e("table",Je,[e("thead",Xe,[e("tr",null,[Ye,Ze,et,tt,st,e("th",ot,[nt,e("div",at,[(d(),_("svg",{xmlns:"http://www.w3.org/2000/svg",class:"h-4 w-4 inline transition-transform hover:scale-[1.1] cursor-pointer",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",onClick:i[0]||(i[0]=()=>{s.value.visible=!s.value.visible,s.value.focus=s.value.visible?"queue":null})},rt))])]),it])]),o.tasks===null?(d(),$(Pe,{key:0})):w("",!0),o.tasks&&o.tasks.length===0?(d(),_("tbody",ct,dt)):w("",!0),e("tbody",pt,[(d(!0),_(y,null,M(o.tasks,c=>(d(),_("tr",{class:v(["cursor-pointer hover:bg-indigo-100/10 transition-colors",{"bg-blue-300/30":t.value.includes(c.id)}]),onClick:u=>l.$router.push({name:`${l.$route.name}-task`,params:{uuid:c.uuid}})},[e("td",ht,p(c.id),1),e("td",mt,p(c.name.substring(0,30))+p(c.name.length>30?"...":""),1),e("td",ft,[h(N,{status:c.status},null,8,["status"])]),e("td",xt,p(c.attempts),1),e("td",vt,p(c.created),1),e("td",yt,p(c.queue),1),gt],10,_t))),256))])])])])])]),s.value.visible?(d(),$(je,{key:0,visible:s.value.visible,focus:s.value.focus},null,8,["visible","focus"])):w("",!0)],64))}},bt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"recent"}),(t,r)=>(d(),$(A,{title:"Recent tasks",description:"Tasks that have been added or processed in the queue recently.",tasks:n.value},null,8,["tasks"]))}},wt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{status:"queued"}),(t,r)=>(d(),$(A,{title:"Queued tasks",description:"Tasks that have been added to the queue recently.",tasks:n.value},null,8,["tasks"]))}},kt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"failed"}),(t,r)=>(d(),$(A,{title:"Failed tasks",description:"Tasks that permanently failed after they have reached their max number of attempts.",tasks:n.value},null,8,["tasks"]))}};const $t={class:"absolute flex items-center justify-center w-2 h-2 bg-gray-200 rounded-full -left-1 ring-1 mt-3 ring-white"},Ct={props:{status:String,classes:{type:Array,default:[]}},setup(o){return(n,t)=>(d(),_("span",$t))}};var qt=C(Ct,[["__scopeId","data-v-35155177"]]);const I=o=>(J("data-v-5e0e697d"),o=o(),X(),o),Tt={class:"text-4xl mb-2"},St={class:"flex"},At={class:"basis-[400px] shrink-0 pr-6 w-2/12"},It={class:"flex-initial sticky ml-4 mt-12"},Rt={class:"relative border-l border-gray-200 dark:border-gray-700"},Lt={class:"text-gray-900"},Ut={key:0,class:"bg-blue-100 text-blue-800 text-xs font-medium mr-2 inline-block mb-1 px-1.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800"},Vt={class:"block mb-2 mt-2 text-xs text-black/70 font-normal leading-none"},Mt={class:"cursor-default"},Nt={class:"basis-auto overflow-x-auto pr-12"},Pt=I(()=>e("h2",{class:"text-2xl"},"Task Exception",-1)),Bt={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Dt={key:1,class:"mt-12"},Et=I(()=>e("h2",{class:"text-2xl"},"Task Payload",-1)),Ft={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Ot=I(()=>e("div",{class:"basis-[250px] shrink-0 px-6"},[e("h2",{class:"text-3xl"},"Actions"),e("button",{class:"bg-gray-200 text-black/20 cursor-not-allowed mt-4 w-full rounded px-4 py-2"}," Retry "),e("span",{class:"text-xs text-gray-800 mt-2 inline-block"},"Retrying tasks is not available yet.")],-1)),jt={setup(o){const n=V(),t=x({id:null,status:"loading"});fetch(`http://localhost:8000/cloud-tasks-api/task/${n.params.uuid}`).then(s=>s.json()).then(s=>t.value=s);const r={queued:"Added to the queue",running:"Running",successful:"Successful",error:"An error occurred",failed:"Failed permanently"};return(s,a)=>{const l=k("Popper");return d(),_(y,null,[e("h1",Tt,"Task #"+p(t.value.id),1),h(N,{status:t.value.status,classes:["text-sm"]},null,8,["status"]),e("div",St,[e("div",At,[e("div",It,[e("ol",Rt,[(d(!0),_(y,null,M(t.value.events,(i,c)=>(d(),_("li",{class:v(["ml-10 pt-1 mb-6",[`event-${i.status}`]])},[h(qt,{status:i.status},null,8,["status"]),e("h3",Lt,[b(p(r[i.status]||i.status)+" ",1),e("div",null,[i.queue?(d(),_("span",Ut,p(t.value.queue),1)):w("",!0)])]),h(l,{content:i.datetime,hover:!0,arrow:!0,placement:"right"},{default:f(()=>[e("time",Vt,[e("span",Mt,p(i.diff),1)])]),_:2},1032,["content"])],2))),256))])])]),e("div",Nt,[t.value.exception?(d(),_(y,{key:0},[Pt,e("pre",Bt,p(t.value.exception),1)],64)):w("",!0),t.value.payload?(d(),_("div",Dt,[Et,e("pre",Ft,p(t.value.payload),1)])):w("",!0)]),Ot])],64)}}};var R=C(jt,[["__scopeId","data-v-5e0e697d"]]);const Ht=[{name:"home",path:"/",component:Le},{name:"recent",path:"/recent",component:bt,meta:{route:"recent"}},{name:"recent-task",path:"/recent/:uuid",component:R,meta:{route:"recent"}},{name:"queued",path:"/queued",component:wt,meta:{route:"queued"}},{name:"queued-task",path:"/queued/:uuid",component:R,meta:{route:"queued"}},{name:"failed",path:"/failed",component:kt,meta:{route:"failed"}},{name:"failed-task",path:"/failed/:uuid",component:R,meta:{route:"failed"}}];let P=null;"CloudTasks"in window&&(P=`/${window.CloudTasks.path}`);const zt=Y({history:Z(P),routes:Ht});ee(me).use(zt).component("Popper",te).mount("#app"); diff --git a/dashboard/dist/assets/index.643ccf47.js b/dashboard/dist/assets/index.643ccf47.js new file mode 100644 index 0000000..e0408f9 --- /dev/null +++ b/dashboard/dist/assets/index.643ccf47.js @@ -0,0 +1 @@ +var B=Object.defineProperty;var L=Object.getOwnPropertySymbols;var D=Object.prototype.hasOwnProperty,E=Object.prototype.propertyIsEnumerable;var U=(o,n,t)=>n in o?B(o,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[n]=t,q=(o,n)=>{for(var t in n||(n={}))D.call(n,t)&&U(o,t,n[t]);if(L)for(var t of L(n))E.call(n,t)&&U(o,t,n[t]);return o};var T=(o,n,t)=>new Promise((r,s)=>{var a=c=>{try{i(t.next(c))}catch(u){s(u)}},l=c=>{try{i(t.throw(c))}catch(u){s(u)}},i=c=>c.done?r(c.value):Promise.resolve(c.value).then(a,l);i((t=t.apply(o,n)).next())});import{r as k,o as d,c as _,a as h,w as f,n as v,F as y,b,d as e,e as x,t as p,f as F,g as O,u as j,h as V,i as H,j as z,k as Q,v as K,l as W,m as G,p as $,q as w,s as M,x as J,y as X,z as Y,A as Z,B as ee,C as te}from"./vendor.f52c9be3.js";const se=function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const a of s)if(a.type==="childList")for(const l of a.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&r(l)}).observe(document,{childList:!0,subtree:!0});function t(s){const a={};return s.integrity&&(a.integrity=s.integrity),s.referrerpolicy&&(a.referrerPolicy=s.referrerpolicy),s.crossorigin==="use-credentials"?a.credentials="include":s.crossorigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(s){if(s.ep)return;s.ep=!0;const a=t(s);fetch(s.href,a)}};se();var C=(o,n)=>{const t=o.__vccOpts||o;for(const[r,s]of n)t[r]=s;return t};const oe={},ne=b("Dashboard "),ae=b("Recent "),le=b("Queued "),re=b("Failed ");function ie(o,n){var r,s,a,l,i,c,u,m,g;const t=k("router-link");return d(),_(y,null,[h(t,{to:{name:"home"},class:"block p-4 rounded mb-2 cursor-pointer"},{default:f(()=>[ne]),_:1}),h(t,{to:{name:"recent"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((a=(s=(r=o.$route)==null?void 0:r.matched[0])==null?void 0:s.meta)==null?void 0:a.route)==="recent"}])},{default:f(()=>[ae]),_:1},8,["class"]),h(t,{to:{name:"queued"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((c=(i=(l=o.$route)==null?void 0:l.matched[0])==null?void 0:i.meta)==null?void 0:c.route)==="queued"}])},{default:f(()=>[le]),_:1},8,["class"]),h(t,{to:{name:"failed"},class:v(["block p-4 rounded mb-2",{"router-link-active":((g=(m=(u=o.$route)==null?void 0:u.matched[0])==null?void 0:m.meta)==null?void 0:g.route)==="failed"}])},{default:f(()=>[re]),_:1},8,["class"])],64)}var ce=C(oe,[["render",ie]]);const ue={components:{Menu:ce}},de={class:"flex"},pe={class:"basis-auto w-[250px] shrink-0 bg-white p-6 min-h-screen"},_e={class:"flex-1 max-w-[calc(100%-250px)] p-6"};function he(o,n,t,r,s,a){const l=k("Menu"),i=k("router-view");return d(),_("div",de,[e("aside",pe,[h(l)]),e("div",_e,[h(i)])])}var me=C(ue,[["render",he]]);const fe=e("h3",{class:"text-3xl mb-4"},"All tasks",-1),xe={class:"grid grid-cols-3 gap-4"},ve=["textContent"],ye=e("span",{class:"text-gray-600"},"this minute",-1),ge=["textContent"],be=e("span",{class:"text-gray-600"},"this hour",-1),we=["textContent"],ke=e("span",{class:"text-gray-600"},"today",-1),$e=e("h3",{class:"text-3xl mb-4 mt-8"},"Failed tasks",-1),Ce={class:"grid grid-cols-3 gap-4"},qe=["textContent"],Te=e("span",{class:"text-gray-600"},"this minute",-1),Se=["textContent"],Ae=e("span",{class:"text-gray-600"},"this hour",-1),Ie=["textContent"],Re=e("span",{class:"text-gray-600"},"today",-1),Le={setup(o){const n=x({recent:{this_minute:"...",this_hour:"...",today:"..."},failed:{this_minute:"...",this_hour:"...",today:"..."}});return fetch("http://github.com/service/http://localhost:8000/cloud-tasks-api/dashboard").then(t=>t.json()).then(t=>n.value=t),(t,r)=>{const s=k("router-link");return d(),_(y,null,[fe,e("div",xe,[h(s,{to:{name:"recent",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_minute)},null,8,ve),ye]}),_:1},8,["to"]),h(s,{to:{name:"recent",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_hour)},null,8,ge),be]}),_:1},8,["to"]),h(s,{to:{name:"recent"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_day)},null,8,we),ke]}),_:1})]),$e,e("div",Ce,[h(s,{to:{name:"failed",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_minute)},null,8,qe),Te]}),_:1},8,["to"]),h(s,{to:{name:"failed",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_hour)},null,8,Se),Ae]}),_:1},8,["to"]),h(s,{to:{name:"failed"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_day)},null,8,Ie),Re]}),_:1})])],64)}}};function S(t){return T(this,arguments,function*(o,n={}){const r=function(l){return T(this,null,function*(){const i=new URL(window.location.href),c=new URLSearchParams(i.search);for(const[u,m]of Object.entries(n))c.append(u,m);fetch(`http://localhost:8000/cloud-tasks-api/tasks?${c.toString()}&test=oke`).then(u=>u.json()).then(u=>{l.value=u})})};r(o);let s=setInterval(()=>r(o),3e3);F(function(){setTimeout(()=>r(o))});const a=function(){document.visibilityState==="visible"?(r(o),clearInterval(s),s=setInterval(()=>r(o),3e3)):document.visibilityState==="hidden"&&clearInterval(s)};document.addEventListener("visibilitychange",a),O(()=>{clearInterval(s),document.removeEventListener("visibilitychange",a)})})}const N={props:{status:String,classes:{type:Array,default:[]}},setup(o){function n(t){return t.charAt(0).toUpperCase()+t.slice(1)}return(t,r)=>(d(),_("span",{class:v(["px-2 inline-flex text-xs leading-5 font-semibold rounded-full",[`task-${o.status}`,...o.classes]])},p(n(o.status)),3))}},Ue={},Ve=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},[e("svg",{class:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"indigo","stroke-width":"4"}),e("path",{class:"opacity-75",fill:"indigo",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})])])],-1),Me=[Ve];function Ne(o,n){return d(),_("tbody",null,Me)}var Pe=C(Ue,[["render",Ne]]);const Be=e("label",{for:"queue",class:"block mb-2 font-medium"},"Queue",-1),De=["onKeyup"],Ee=e("label",{for:"status",class:"block mb-2 mt-6 font-medium"},"Status",-1),Fe=W('',6),Oe=[Fe],je={props:{focus:String},setup(o){const n=o,t=j(),r=V(),s=x(!1),a=x(null),l=x(null);function i(){t.push({name:r.name,query:q(q({},a.value.value?{queue:a.value.value}:{}),l.value?{status:l.value}:{})})}function c(u){u===""&&i()}return H(()=>{setTimeout(()=>s.value=!0),n.focus==="queue"&&a.value.focus()}),(u,m)=>(d(),_("div",{class:v(["w-[300px] fixed transition-transform right-0 top-0 p-6 px-6 shadow-2xl h-screen bg-white",{"translate-x-[300px]":s.value===!1}])},[Be,e("input",{type:"text",name:"queue",id:"queue",ref_key:"queue",ref:a,class:"bg-white py-2 px-3 w-full rounded border",onKeyup:[z(i,["enter"]),m[0]||(m[0]=g=>c(g.target.value))]},null,40,De),Ee,Q(e("select",{name:"status",id:"status","onUpdate:modelValue":m[1]||(m[1]=g=>l.value=g),class:"bg-white py-2 px-3 w-full rounded border"},Oe,512),[[K,l.value]]),e("button",{class:"bg-indigo-500 w-full mt-4 text-indigo-100 rounded py-2",onClick:i}," Apply Filter (or Press Enter) ")],2))}};const He={class:"text-4xl mb-2"},ze={class:"text-lg"},Qe={class:"flex flex-row mt-6"},Ke={class:"flex-1"},We={class:"align-middle"},Ge={class:"shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"},Je={class:"table-fixed divide-y divide-gray-200 w-full"},Xe={class:"bg-gray-50"},Ye=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[50px]"}," # ",-1),Ze=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider max-w-xl w-[300px]"}," Name ",-1),et=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[100px]"}," Status ",-1),tt=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px] text-center"}," Attempts ",-1),st=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[200px]"}," Created ",-1),ot={scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"},nt=b(" Queue "),at={class:"inline relative"},lt=e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"},null,-1),rt=[lt],it=e("th",{scope:"col",class:"relative px-6 py-3"},[e("span",{class:"sr-only"},"Edit")],-1),ct={key:1},ut=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},"No results.")],-1),dt=[ut],pt={class:"bg-white divide-y divide-gray-200"},_t=["onClick"],ht={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-900"},mt={class:"px-6 py-4 whitespace-nowrap text-ellipsis text-sm text-gray-900"},ft={class:"px-6 py-4 whitespace-nowrap"},xt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center"},vt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},yt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},gt=e("td",{class:"px-6 py-4 whitespace-nowrap text-right text-sm font-medium"},[e("a",{href:"#",class:"text-indigo-600 hover:text-indigo-900"},"View")],-1),A={props:{title:String,description:String,tasks:Array},setup(o){const n=o,t=x([]),r=x([]),s=x({visible:!1,focus:null});function a(l){t.value.push(l.id),setTimeout(()=>{t.value.splice(t.value.indexOf(l.id),1)},1e3)}return G(()=>n.tasks,(l,i)=>{var c;if(!!i){r.value=[],i.map((u,m)=>{r[u.id]=m});for(const u of l)(r[u.id]===void 0||((c=i[r[u.id]])==null?void 0:c.status)!==u.status)&&a(u)}}),(l,i)=>(d(),_(y,null,[e("h1",He,p(o.title),1),e("p",ze,p(o.description),1),e("div",Qe,[e("div",Ke,[e("div",We,[e("div",Ge,[e("table",Je,[e("thead",Xe,[e("tr",null,[Ye,Ze,et,tt,st,e("th",ot,[nt,e("div",at,[(d(),_("svg",{xmlns:"http://www.w3.org/2000/svg",class:"h-4 w-4 inline transition-transform hover:scale-[1.1] cursor-pointer",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",onClick:i[0]||(i[0]=()=>{s.value.visible=!s.value.visible,s.value.focus=s.value.visible?"queue":null})},rt))])]),it])]),o.tasks===null?(d(),$(Pe,{key:0})):w("",!0),o.tasks&&o.tasks.length===0?(d(),_("tbody",ct,dt)):w("",!0),e("tbody",pt,[(d(!0),_(y,null,M(o.tasks,c=>(d(),_("tr",{class:v(["cursor-pointer hover:bg-indigo-100/10 transition-colors",{"bg-blue-300/30":t.value.includes(c.id)}]),onClick:u=>l.$router.push({name:`${l.$route.name}-task`,params:{uuid:c.uuid}})},[e("td",ht,p(c.id),1),e("td",mt,p(c.name.substring(0,30))+p(c.name.length>30?"...":""),1),e("td",ft,[h(N,{status:c.status},null,8,["status"])]),e("td",xt,p(c.attempts),1),e("td",vt,p(c.created),1),e("td",yt,p(c.queue),1),gt],10,_t))),256))])])])])])]),s.value.visible?(d(),$(je,{key:0,visible:s.value.visible,focus:s.value.focus},null,8,["visible","focus"])):w("",!0)],64))}},bt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"recent"}),(t,r)=>(d(),$(A,{title:"Recent tasks",description:"Tasks that have been added or processed in the queue recently.",tasks:n.value},null,8,["tasks"]))}},wt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{status:"queued"}),(t,r)=>(d(),$(A,{title:"Queued tasks",description:"Tasks that have been added to the queue recently.",tasks:n.value},null,8,["tasks"]))}},kt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"failed"}),(t,r)=>(d(),$(A,{title:"Failed tasks",description:"Tasks that permanently failed after they have reached their max number of attempts.",tasks:n.value},null,8,["tasks"]))}};const $t={class:"absolute flex items-center justify-center w-2 h-2 bg-gray-200 rounded-full -left-1 ring-1 mt-3 ring-white"},Ct={props:{status:String,classes:{type:Array,default:[]}},setup(o){return(n,t)=>(d(),_("span",$t))}};var qt=C(Ct,[["__scopeId","data-v-35155177"]]);const I=o=>(J("data-v-5e0e697d"),o=o(),X(),o),Tt={class:"text-4xl mb-2"},St={class:"flex"},At={class:"basis-[400px] shrink-0 pr-6 w-2/12"},It={class:"flex-initial sticky ml-4 mt-12"},Rt={class:"relative border-l border-gray-200 dark:border-gray-700"},Lt={class:"text-gray-900"},Ut={key:0,class:"bg-blue-100 text-blue-800 text-xs font-medium mr-2 inline-block mb-1 px-1.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800"},Vt={class:"block mb-2 mt-2 text-xs text-black/70 font-normal leading-none"},Mt={class:"cursor-default"},Nt={class:"basis-auto overflow-x-auto pr-12"},Pt=I(()=>e("h2",{class:"text-2xl"},"Task Exception",-1)),Bt={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Dt={key:1,class:"mt-12"},Et=I(()=>e("h2",{class:"text-2xl"},"Task Payload",-1)),Ft={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Ot=I(()=>e("div",{class:"basis-[250px] shrink-0 px-6"},[e("h2",{class:"text-3xl"},"Actions"),e("button",{class:"bg-gray-200 text-black/20 cursor-not-allowed mt-4 w-full rounded px-4 py-2"}," Retry "),e("span",{class:"text-xs text-gray-800 mt-2 inline-block"},"Retrying tasks is not available yet.")],-1)),jt={setup(o){const n=V(),t=x({id:null,status:"loading"});fetch(`http://localhost:8000/cloud-tasks-api/task/${n.params.uuid}`).then(s=>s.json()).then(s=>t.value=s);const r={queued:"Added to the queue",running:"Running",successful:"Successful",error:"An error occurred",failed:"Failed permanently"};return(s,a)=>{const l=k("Popper");return d(),_(y,null,[e("h1",Tt,"Task #"+p(t.value.id),1),h(N,{status:t.value.status,classes:["text-sm"]},null,8,["status"]),e("div",St,[e("div",At,[e("div",It,[e("ol",Rt,[(d(!0),_(y,null,M(t.value.events,(i,c)=>(d(),_("li",{class:v(["ml-10 pt-1 mb-6",[`event-${i.status}`]])},[h(qt,{status:i.status},null,8,["status"]),e("h3",Lt,[b(p(r[i.status]||i.status)+" ",1),e("div",null,[i.queue?(d(),_("span",Ut,p(t.value.queue),1)):w("",!0)])]),h(l,{content:i.datetime,hover:!0,arrow:!0,placement:"right"},{default:f(()=>[e("time",Vt,[e("span",Mt,p(i.diff),1)])]),_:2},1032,["content"])],2))),256))])])]),e("div",Nt,[t.value.exception?(d(),_(y,{key:0},[Pt,e("pre",Bt,p(t.value.exception),1)],64)):w("",!0),t.value.payload?(d(),_("div",Dt,[Et,e("pre",Ft,p(t.value.payload),1)])):w("",!0)]),Ot])],64)}}};var R=C(jt,[["__scopeId","data-v-5e0e697d"]]);const Ht=[{name:"home",path:"/",component:Le},{name:"recent",path:"/recent",component:bt,meta:{route:"recent"}},{name:"recent-task",path:"/recent/:uuid",component:R,meta:{route:"recent"}},{name:"queued",path:"/queued",component:wt,meta:{route:"queued"}},{name:"queued-task",path:"/queued/:uuid",component:R,meta:{route:"queued"}},{name:"failed",path:"/failed",component:kt,meta:{route:"failed"}},{name:"failed-task",path:"/failed/:uuid",component:R,meta:{route:"failed"}}];let P=null;"CloudTasks"in window&&(P=`/${window.CloudTasks.path}`);const zt=Y({history:Z(P),routes:Ht});ee(me).use(zt).component("Popper",te).mount("#app"); diff --git a/dashboard/dist/index.html b/dashboard/dist/index.html index ec8bf2b..95bbeb5 100644 --- a/dashboard/dist/index.html +++ b/dashboard/dist/index.html @@ -5,7 +5,7 @@ Vite App - + diff --git a/dashboard/dist/manifest.json b/dashboard/dist/manifest.json index 3756dd9..2955bc7 100644 --- a/dashboard/dist/manifest.json +++ b/dashboard/dist/manifest.json @@ -1,6 +1,6 @@ { "index.html": { - "file": "assets/index.481c4ac3.js", + "file": "assets/index.643ccf47.js", "src": "index.html", "isEntry": true, "imports": [ diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index bde1ef3..61de1ad 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "cloud-tasks-dashboard", "version": "0.0.0", "dependencies": { "vue": "^3.2.25", diff --git a/dashboard/src/api.js b/dashboard/src/api.js index cbed527..68a6e3c 100644 --- a/dashboard/src/api.js +++ b/dashboard/src/api.js @@ -2,7 +2,13 @@ import { onUnmounted, watch } from 'vue' import { onBeforeRouteUpdate } from 'vue-router' export async function fetchTasks(into, query = {}) { + let paused = false + const f = async function (into) { + if (paused) { + return + } + const url = new URL(window.location.href) const queryParams = new URLSearchParams(url.search) @@ -10,12 +16,14 @@ export async function fetchTasks(into, query = {}) { queryParams.append(name, value) } + paused = true fetch( - `http://localhost:8000/cloud-tasks-api/tasks?${queryParams.toString()}` + `${import.meta.env.VITE_API_URL}/cloud-tasks-api/tasks?${queryParams.toString()}` ) .then((response) => response.json()) .then((response) => { into.value = response + paused = false }) } @@ -42,5 +50,6 @@ export async function fetchTasks(into, query = {}) { onUnmounted(() => { clearInterval(interval) document.removeEventListener('visibilitychange', onVisibilityChange) + paused = false }) } diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue index 5b36f87..02d297d 100644 --- a/dashboard/src/components/Dashboard.vue +++ b/dashboard/src/components/Dashboard.vue @@ -16,7 +16,7 @@ const dashboard = ref({ const tsLoaded = Math.floor(Date.now() / 1000) -fetch('http://github.com/service/http://localhost:8000/cloud-tasks-api/dashboard') +fetch(`${import.meta.env.VITE_API_URL}/cloud-tasks-api/dashboard`) .then((response) => response.json()) .then((response) => (dashboard.value = response)) diff --git a/dashboard/src/components/FilterCard.vue b/dashboard/src/components/FilterCard.vue index c9364b6..a24526b 100644 --- a/dashboard/src/components/FilterCard.vue +++ b/dashboard/src/components/FilterCard.vue @@ -23,7 +23,7 @@ v-model="status" class="bg-white py-2 px-3 w-full rounded border" > - + diff --git a/dashboard/src/components/Task.vue b/dashboard/src/components/Task.vue index e1bf144..f2937b1 100644 --- a/dashboard/src/components/Task.vue +++ b/dashboard/src/components/Task.vue @@ -11,7 +11,7 @@ const task = ref({ status: 'loading', }) -fetch(`http://localhost:8000/cloud-tasks-api/task/${route.params.uuid}`) +fetch(`${import.meta.env.VITE_API_URL}/cloud-tasks-api/task/${route.params.uuid}`) .then((response) => response.json()) .then((response) => (task.value = response)) diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 0176c91..05b204b 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -10,15 +10,12 @@ use Google\Protobuf\Timestamp; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Queue\Queue as LaravelQueue; -use Illuminate\Support\InteractsWithTime; use Illuminate\Support\Str; use function Safe\json_encode; use function Safe\json_decode; class CloudTasksQueue extends LaravelQueue implements QueueContract { - use InteractsWithTime; - /** * @var CloudTasksClient */ From 758bd59c724b258a88e46f82687206547c41c069 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Fri, 4 Mar 2022 14:34:20 +0100 Subject: [PATCH 019/258] Add enable/disable switch for Monitoring --- config/cloud-tasks.php | 9 ++++ src/CloudTasks.php | 20 +++++++ src/CloudTasksQueue.php | 4 +- src/CloudTasksServiceProvider.php | 74 ++++++++++++++++++++----- tests/CloudTasksMonitoringTest.php | 86 ++++++++++++++++++++++++++++++ tests/TestCase.php | 8 +++ 6 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 config/cloud-tasks.php diff --git a/config/cloud-tasks.php b/config/cloud-tasks.php new file mode 100644 index 0000000..dd26f16 --- /dev/null +++ b/config/cloud-tasks.php @@ -0,0 +1,9 @@ + [ + 'enabled' => env('CLOUD_TASKS_MONITOR_ENABLED', false), + ], +]; diff --git a/src/CloudTasks.php b/src/CloudTasks.php index fe58534..367b90e 100644 --- a/src/CloudTasks.php +++ b/src/CloudTasks.php @@ -36,4 +36,24 @@ public static function check($request) { return (static::$authUsing)($request); } + + /** + * Determine if the monitor is enabled. + * + * @return bool + */ + public static function monitorEnabled(): bool + { + return config('cloud-tasks.monitor.enabled') === true; + } + + /** + * Determine if the monitor is disabled. + * + * @return bool + */ + public static function monitorDisabled(): bool + { + return self::monitorEnabled() === false; + } } diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 05b204b..39562d3 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -120,7 +120,9 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0) $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); } - MonitoringService::make()->addToMonitor($queue, $task); + if (CloudTasks::monitorEnabled()) { + MonitoringService::make()->addToMonitor($queue, $task); + } $createdTask = CloudTasksApi::createTask($queueName, $task); diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index 9dbfc67..fad1ded 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -22,6 +22,7 @@ public function boot(QueueManager $queue, Router $router): void $this->registerClient(); $this->registerConnector($queue); + $this->registerConfig(); $this->registerViews(); $this->registerAssets(); $this->registerMigrations(); @@ -77,20 +78,41 @@ private function registerConnector(QueueManager $queue): void }); } + private function registerConfig(): void + { + $this->publishes([ + __DIR__ . '/../config/cloud-tasks.php' => config_path('cloud-tasks.php'), + ], ['cloud-tasks']); + + $this->mergeConfigFrom(__DIR__ . '/../config/cloud-tasks.php', 'cloud-tasks'); + } + private function registerViews(): void { + if (CloudTasks::monitorDisabled()) { + return; + } + $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); } private function registerAssets(): void { + if (CloudTasks::monitorDisabled()) { + return; + } + $this->publishes([ __DIR__ . '/../dashboard/dist' => public_path('vendor/cloud-tasks'), - ], ['cloud-tasks-assets']); + ], ['cloud-tasks']); } private function registerMigrations(): void { + if (CloudTasks::monitorDisabled()) { + return; + } + $this->loadMigrationsFrom([ __DIR__ . '/../migrations', ]); @@ -98,7 +120,11 @@ private function registerMigrations(): void private function registerRoutes(Router $router): void { - $router->post('handle-task', [TaskHandler::class, 'handle']); + $router->post('handle-task', [TaskHandler::class, 'handle'])->name('cloud-tasks.handle-task'); + + if (config('cloud-tasks.monitor.enabled') === false) { + return; + } $router->middleware(Authenticate::class)->group(function () use ($router) { $router->get('cloud-tasks/{view?}', function () { @@ -116,41 +142,61 @@ private function registerRoutes(Router $router): void 'cloud-tasks.index' ); - $router->get('cloud-tasks-api/dashboard', [CloudTasksApiController::class, 'dashboard']); - $router->get('cloud-tasks-api/tasks', [CloudTasksApiController::class, 'tasks']); - $router->get('cloud-tasks-api/task/{uuid}', [CloudTasksApiController::class, 'task']); + $router->get('cloud-tasks-api/dashboard', [CloudTasksApiController::class, 'dashboard'])->name('cloud-tasks.api.dashboard'); + $router->get('cloud-tasks-api/tasks', [CloudTasksApiController::class, 'tasks'])->name('cloud-tasks.api.tasks'); + $router->get('cloud-tasks-api/task/{uuid}', [CloudTasksApiController::class, 'task'])->name('cloud-tasks.api.task'); }); } private function registerMonitoring(): void { + app('events')->listen(JobFailed::class, function (JobFailed $event) { + if (!$event->job instanceof CloudTasksJob) { + return; + } + + $config = $event->job->cloudTasksQueue->config; + + app('queue.failer')->log( + $config['connection'], $event->job->getQueue() ?: $config['queue'], + $event->job->getRawBody(), $event->exception + ); + }); + app('events')->listen(JobProcessing::class, function (JobProcessing $event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + if ($event->job instanceof CloudTasksJob) { MonitoringService::make()->markAsRunning($event->job->uuid()); } }); app('events')->listen(JobProcessed::class, function (JobProcessed $event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + if ($event->job instanceof CloudTasksJob) { MonitoringService::make()->markAsSuccessful($event->job->uuid()); } }); app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + MonitoringService::make()->markAsError($event); }); app('events')->listen(JobFailed::class, function ($event) { - MonitoringService::make()->markAsFailed( - $event - ); - - $config = $event->job->cloudTasksQueue->config; + if (!CloudTasks::monitorEnabled()) { + return; + } - app('queue.failer')->log( - $config['connection'], $event->job->getQueue() ?: $config['queue'], - $event->job->getRawBody(), $event->exception - ); + MonitoringService::make()->markAsFailed($event); }); } } diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php index 4acab62..861b879 100644 --- a/tests/CloudTasksMonitoringTest.php +++ b/tests/CloudTasksMonitoringTest.php @@ -3,8 +3,12 @@ namespace Tests; use Google\Cloud\Tasks\V2\RetryConfig; +use Illuminate\Routing\Route; +use Illuminate\Routing\Router; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Schema; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksServiceProvider; use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; use Stackkit\LaravelGoogleCloudTasksQueue\StackkitCloudTask; use Tests\Support\FailingJob; @@ -260,6 +264,22 @@ public function when_a_job_is_dispatched_it_will_be_added_to_the_monitor() $this->assertSame($payload, $job->payload); } + /** + * @test + */ + public function when_monitoring_is_disabled_jobs_will_not_be_added_to_the_monitor() + { + // Arrange + CloudTasksApi::fake(); + config()->set('cloud-tasks.monitor.enabled', false); + + // Act + $this->dispatch(new SimpleJob()); + + // Assert + $this->assertDatabaseCount((new StackkitCloudTask())->getTable(), 0); + } + /** * @test */ @@ -370,4 +390,70 @@ public function when_a_job_fails_it_will_be_updated_in_the_monitor() $events[6] ); } + + /** + * @test + */ + public function test_publish() + { + // Arrange + config()->set('cloud-tasks.monitor.enabled', true); + + // Act & Assert + $expectedPublishBase = dirname(__DIR__); + + $this->artisan('vendor:publish --tag=cloud-tasks --force') + ->expectsOutput('Copied File [' . $expectedPublishBase . '/config/cloud-tasks.php] To [/config/cloud-tasks.php]') + ->expectsOutput('Copied Directory [' . $expectedPublishBase . '/dashboard/dist] To [/public/vendor/cloud-tasks]') + ->expectsOutput('Publishing complete.'); + } + + /** + * @test + */ + public function when_monitoring_is_enabled_it_adds_the_necessary_routes() + { + // Act + $routes = app(Router::class)->getRoutes(); + + // Assert + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.handle-task')); + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.index')); + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.dashboard')); + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.tasks')); + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.task')); + } + + /** + * @test + */ + public function when_monitoring_is_enabled_it_adds_the_necessary_migrations() + { + $this->assertTrue(in_array(dirname(__DIR__) . '/src/../migrations', app('migrator')->paths())); + } + + /** + * @test + */ + public function when_monitoring_is_disabled_it_adds_the_necessary_migrations() + { + $this->assertEmpty(app('migrator')->paths()); + } + + /** + * @test + */ + public function when_monitoring_is_disabled_it_does_not_add_the_monitor_routes() + { + // Act + $routes = app(Router::class)->getRoutes(); + + // Assert + $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.handle-task')); + $this->assertNull($routes->getByName('cloud-tasks.index')); + $this->assertNull($routes->getByName('cloud-tasks.api.dashboard')); + $this->assertNull($routes->getByName('cloud-tasks.api.tasks')); + $this->assertNull($routes->getByName('cloud-tasks.api.task')); + + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index dda782d..29231b8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -96,6 +96,14 @@ protected function getEnvironmentSetUp($app) ]); $app['config']->set('queue.failed.driver', 'database-uuids'); $app['config']->set('queue.failed.database', 'testbench'); + + $disableMonitorPrefix = 'when_monitoring_is_disabled'; + + if (substr($this->getName(), 0, strlen($disableMonitorPrefix)) === $disableMonitorPrefix) { + $app['config']->set('cloud-tasks.monitor.enabled', false); + } else { + $app['config']->set('cloud-tasks.monitor.enabled', true); + } } protected function setConfigValue($key, $value) From 0ffde404bce5b665a6e641711beae66f4aa088dc Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Fri, 4 Mar 2022 14:48:46 +0100 Subject: [PATCH 020/258] Run Larastan --- .github/workflows/run-tests.yml | 1 + src/CloudTasksServiceProvider.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2869804..267bdda 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -62,4 +62,5 @@ jobs: CI_CLOUD_TASKS_CUSTOM_QUEUE: ${{ matrix.payload.queue }} run: | echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json + vendor/bin/phpstan analyse --memory-limit=2G vendor/bin/phpunit diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index fad1ded..bfcf6d3 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -90,7 +90,8 @@ private function registerConfig(): void private function registerViews(): void { if (CloudTasks::monitorDisabled()) { - return; + // Larastan needs this view registered to check the service provider correctly. + // return; } $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); From fada6923792639cbbd2aab29c92310db5dff49df Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Fri, 4 Mar 2022 14:50:41 +0100 Subject: [PATCH 021/258] Skip Larastan in Github actions --- .github/workflows/run-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 267bdda..2869804 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -62,5 +62,4 @@ jobs: CI_CLOUD_TASKS_CUSTOM_QUEUE: ${{ matrix.payload.queue }} run: | echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json - vendor/bin/phpstan analyse --memory-limit=2G vendor/bin/phpunit From 1ec96f606b4822a737099ea555dc42a46ec93cfc Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 6 Mar 2022 16:00:40 +0100 Subject: [PATCH 022/258] Move add task to monitor to service provider --- src/CloudTasksQueue.php | 6 +----- src/CloudTasksServiceProvider.php | 8 ++++++++ src/TaskCreated.php | 12 +++++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 39562d3..937bb2e 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -120,13 +120,9 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0) $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); } - if (CloudTasks::monitorEnabled()) { - MonitoringService::make()->addToMonitor($queue, $task); - } - $createdTask = CloudTasksApi::createTask($queueName, $task); - event(new TaskCreated($createdTask)); + event((new TaskCreated)->queue($queue)->task($createdTask)); } private function withUuid(string $payload): string diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index bfcf6d3..6490a60 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -151,6 +151,14 @@ private function registerRoutes(Router $router): void private function registerMonitoring(): void { + app('events')->listen(TaskCreated::class, function (TaskCreated $event) { + if (CloudTasks::monitorDisabled()) { + return; + } + + MonitoringService::make()->addToMonitor($event->queue, $event->task); + }); + app('events')->listen(JobFailed::class, function (JobFailed $event) { if (!$event->job instanceof CloudTasksJob) { return; diff --git a/src/TaskCreated.php b/src/TaskCreated.php index 63055e0..96f0f45 100644 --- a/src/TaskCreated.php +++ b/src/TaskCreated.php @@ -8,10 +8,20 @@ class TaskCreated { + public string $queue; public Task $task; - public function __construct(Task $task) + public function task(Task $task): self { $this->task = $task; + + return $this; + } + + public function queue(string $queue): self + { + $this->queue = $queue; + + return $this; } } From 9e8c094f98b777cd0fbcdeace8cdafadf18533ce Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sun, 6 Mar 2022 17:08:18 +0100 Subject: [PATCH 023/258] Set task scheduled if a delay has been set --- dashboard/src/api.js | 2 +- dashboard/src/components/Dashboard.vue | 2 +- dashboard/src/components/Status.vue | 4 ++-- dashboard/src/components/Task.vue | 10 +++++++++- src/CloudTasksQueue.php | 2 +- src/MonitoringService.php | 16 +++++++++++++--- tests/CloudTasksMonitoringTest.php | 25 +++++++++++++++++++++++++ 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/dashboard/src/api.js b/dashboard/src/api.js index 68a6e3c..50097f1 100644 --- a/dashboard/src/api.js +++ b/dashboard/src/api.js @@ -18,7 +18,7 @@ export async function fetchTasks(into, query = {}) { paused = true fetch( - `${import.meta.env.VITE_API_URL}/cloud-tasks-api/tasks?${queryParams.toString()}` + `${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/tasks?${queryParams.toString()}` ) .then((response) => response.json()) .then((response) => { diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue index 02d297d..1b18376 100644 --- a/dashboard/src/components/Dashboard.vue +++ b/dashboard/src/components/Dashboard.vue @@ -16,7 +16,7 @@ const dashboard = ref({ const tsLoaded = Math.floor(Date.now() / 1000) -fetch(`${import.meta.env.VITE_API_URL}/cloud-tasks-api/dashboard`) +fetch(`${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/dashboard`) .then((response) => response.json()) .then((response) => (dashboard.value = response)) diff --git a/dashboard/src/components/Status.vue b/dashboard/src/components/Status.vue index 35f4de8..e542088 100644 --- a/dashboard/src/components/Status.vue +++ b/dashboard/src/components/Status.vue @@ -28,10 +28,10 @@ function ucfirst(input) { .task-error { @apply bg-red-100/50 text-red-600/50 } -.task-queued { +.task-queued, .task-scheduled { @apply bg-gray-100 text-gray-500 } .task-running { @apply bg-blue-100 text-blue-800 } - \ No newline at end of file + diff --git a/dashboard/src/components/Task.vue b/dashboard/src/components/Task.vue index f2937b1..209c2ee 100644 --- a/dashboard/src/components/Task.vue +++ b/dashboard/src/components/Task.vue @@ -11,11 +11,12 @@ const task = ref({ status: 'loading', }) -fetch(`${import.meta.env.VITE_API_URL}/cloud-tasks-api/task/${route.params.uuid}`) +fetch(`${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/task/${route.params.uuid}`) .then((response) => response.json()) .then((response) => (task.value = response)) const titles = { + scheduled: 'Scheduled', queued: 'Added to the queue', running: 'Running', successful: 'Successful', @@ -47,6 +48,13 @@ const titles = { >{{ task.queue }} +
+ + Scheduled: {{ event['scheduled_at'] }} (UTC) + +
queue($queue)->task($createdTask)); + event((new TaskCreated)->queue($queue)->task($task)); } private function withUuid(string $payload): string diff --git a/src/MonitoringService.php b/src/MonitoringService.php index 8c67e15..561727f 100644 --- a/src/MonitoringService.php +++ b/src/MonitoringService.php @@ -33,9 +33,19 @@ public function addToMonitor(string $queue, Task $task): void { $metadata = new TaskMetadata(); $metadata->payload = $this->getTaskBody($task); - $metadata->addEvent('queued', [ + + $data = [ 'queue' => $queue, - ]); + ]; + + if ($task->hasScheduleTime()) { + $status = 'scheduled'; + $data['scheduled_at'] = $task->getScheduleTime()->toDateTime()->format('Y-m-d H:i:s'); + } else { + $status = 'queued'; + } + + $metadata->addEvent($status, $data); DB::table('stackkit_cloud_tasks') ->insert([ @@ -43,7 +53,7 @@ public function addToMonitor(string $queue, Task $task): void 'name' => $this->getTaskName($task), 'queue' => $queue, 'payload' => $this->getTaskBody($task), - 'status' => 'queued', + 'status' => $status, 'metadata' => $metadata->toJson(), 'created_at' => now()->utc(), 'updated_at' => now()->utc(), diff --git a/tests/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php index 861b879..e21c975 100644 --- a/tests/CloudTasksMonitoringTest.php +++ b/tests/CloudTasksMonitoringTest.php @@ -280,6 +280,31 @@ public function when_monitoring_is_disabled_jobs_will_not_be_added_to_the_monito $this->assertDatabaseCount((new StackkitCloudTask())->getTable(), 0); } + /** + * @test + */ + public function when_a_job_is_scheduled_it_will_be_added_as_such() + { + // Arrange + CloudTasksApi::fake(); + Carbon::setTestNow(now()); + $tasksBefore = StackkitCloudTask::count(); + + $job = $this->dispatch((new SimpleJob())->delay(now()->addSeconds(10))); + $tasksAfter = StackkitCloudTask::count(); + + // Assert + $task = StackkitCloudTask::first(); + $this->assertSame(0, $tasksBefore); + $this->assertSame(1, $tasksAfter); + $this->assertDatabaseHas((new StackkitCloudTask())->getTable(), [ + 'queue' => 'barbequeue', + 'status' => 'scheduled', + 'name' => SimpleJob::class, + ]); + $this->assertEquals(now()->addSeconds(10)->toDateTimeString(), $task->getEvents()[0]['scheduled_at']); + } + /** * @test */ From e22573a950dfceced81b5b0583a5548a39ecc4e9 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Sat, 12 Mar 2022 00:29:35 +0100 Subject: [PATCH 024/258] Build --- dashboard/.env.production | 1 + dashboard/dist/assets/index.050f91c9.js | 1 + .../dist/assets/{index.1ecbaa60.css => index.4b880b6a.css} | 2 +- dashboard/dist/assets/index.643ccf47.js | 1 - dashboard/dist/index.html | 4 ++-- dashboard/dist/manifest.json | 4 ++-- 6 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 dashboard/.env.production create mode 100644 dashboard/dist/assets/index.050f91c9.js rename dashboard/dist/assets/{index.1ecbaa60.css => index.4b880b6a.css} (88%) delete mode 100644 dashboard/dist/assets/index.643ccf47.js diff --git a/dashboard/.env.production b/dashboard/.env.production new file mode 100644 index 0000000..292a14c --- /dev/null +++ b/dashboard/.env.production @@ -0,0 +1 @@ +VITE_API_URL= diff --git a/dashboard/dist/assets/index.050f91c9.js b/dashboard/dist/assets/index.050f91c9.js new file mode 100644 index 0000000..3f20818 --- /dev/null +++ b/dashboard/dist/assets/index.050f91c9.js @@ -0,0 +1 @@ +var B=Object.defineProperty;var U=Object.getOwnPropertySymbols;var D=Object.prototype.hasOwnProperty,E=Object.prototype.propertyIsEnumerable;var L=(o,n,t)=>n in o?B(o,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[n]=t,q=(o,n)=>{for(var t in n||(n={}))D.call(n,t)&&L(o,t,n[t]);if(U)for(var t of U(n))E.call(n,t)&&L(o,t,n[t]);return o};var T=(o,n,t)=>new Promise((i,s)=>{var a=c=>{try{r(t.next(c))}catch(p){s(p)}},l=c=>{try{r(t.throw(c))}catch(p){s(p)}},r=c=>c.done?i(c.value):Promise.resolve(c.value).then(a,l);r((t=t.apply(o,n)).next())});import{r as w,o as u,c as _,a as h,w as f,n as y,F as g,b as k,d as e,e as x,t as d,f as F,g as O,u as j,h as V,i as H,j as z,k as Q,v as K,l as W,m as G,p as $,q as b,s as M,x as J,y as X,z as Y,A as Z,B as ee,C as te}from"./vendor.f52c9be3.js";const se=function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))i(s);new MutationObserver(s=>{for(const a of s)if(a.type==="childList")for(const l of a.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&i(l)}).observe(document,{childList:!0,subtree:!0});function t(s){const a={};return s.integrity&&(a.integrity=s.integrity),s.referrerpolicy&&(a.referrerPolicy=s.referrerpolicy),s.crossorigin==="use-credentials"?a.credentials="include":s.crossorigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function i(s){if(s.ep)return;s.ep=!0;const a=t(s);fetch(s.href,a)}};se();var C=(o,n)=>{const t=o.__vccOpts||o;for(const[i,s]of n)t[i]=s;return t};const oe={},ne=k("Dashboard "),ae=k("Recent "),le=k("Queued "),re=k("Failed ");function ie(o,n){var i,s,a,l,r,c,p,m,v;const t=w("router-link");return u(),_(g,null,[h(t,{to:{name:"home"},class:"block p-4 rounded mb-2 cursor-pointer"},{default:f(()=>[ne]),_:1}),h(t,{to:{name:"recent"},class:y(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((a=(s=(i=o.$route)==null?void 0:i.matched[0])==null?void 0:s.meta)==null?void 0:a.route)==="recent"}])},{default:f(()=>[ae]),_:1},8,["class"]),h(t,{to:{name:"queued"},class:y(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((c=(r=(l=o.$route)==null?void 0:l.matched[0])==null?void 0:r.meta)==null?void 0:c.route)==="queued"}])},{default:f(()=>[le]),_:1},8,["class"]),h(t,{to:{name:"failed"},class:y(["block p-4 rounded mb-2",{"router-link-active":((v=(m=(p=o.$route)==null?void 0:p.matched[0])==null?void 0:m.meta)==null?void 0:v.route)==="failed"}])},{default:f(()=>[re]),_:1},8,["class"])],64)}var ce=C(oe,[["render",ie]]);const ue={components:{Menu:ce}},de={class:"flex"},pe={class:"basis-auto w-[250px] shrink-0 bg-white p-6 min-h-screen"},_e={class:"flex-1 max-w-[calc(100%-250px)] p-6"};function he(o,n,t,i,s,a){const l=w("Menu"),r=w("router-view");return u(),_("div",de,[e("aside",pe,[h(l)]),e("div",_e,[h(r)])])}var me=C(ue,[["render",he]]);const fe=e("h3",{class:"text-3xl mb-4"},"All tasks",-1),xe={class:"grid grid-cols-3 gap-4"},ve=["textContent"],ye=e("span",{class:"text-gray-600"},"this minute",-1),ge=["textContent"],be=e("span",{class:"text-gray-600"},"this hour",-1),ke=["textContent"],we=e("span",{class:"text-gray-600"},"today",-1),$e=e("h3",{class:"text-3xl mb-4 mt-8"},"Failed tasks",-1),Ce={class:"grid grid-cols-3 gap-4"},qe=["textContent"],Te=e("span",{class:"text-gray-600"},"this minute",-1),Se=["textContent"],Ae=e("span",{class:"text-gray-600"},"this hour",-1),Ie=["textContent"],Re=e("span",{class:"text-gray-600"},"today",-1),Ue={setup(o){const n=x({recent:{this_minute:"...",this_hour:"...",today:"..."},failed:{this_minute:"...",this_hour:"...",today:"..."}});return fetch("http://github.com/cloud-tasks-api/dashboard").then(t=>t.json()).then(t=>n.value=t),(t,i)=>{const s=w("router-link");return u(),_(g,null,[fe,e("div",xe,[h(s,{to:{name:"recent",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_minute)},null,8,ve),ye]}),_:1},8,["to"]),h(s,{to:{name:"recent",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_hour)},null,8,ge),be]}),_:1},8,["to"]),h(s,{to:{name:"recent"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_day)},null,8,ke),we]}),_:1})]),$e,e("div",Ce,[h(s,{to:{name:"failed",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_minute)},null,8,qe),Te]}),_:1},8,["to"]),h(s,{to:{name:"failed",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_hour)},null,8,Se),Ae]}),_:1},8,["to"]),h(s,{to:{name:"failed"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:d((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_day)},null,8,Ie),Re]}),_:1})])],64)}}};function S(t){return T(this,arguments,function*(o,n={}){let i=!1;const s=function(r){return T(this,null,function*(){if(i)return;const c=new URL(window.location.href),p=new URLSearchParams(c.search);for(const[m,v]of Object.entries(n))p.append(m,v);i=!0,fetch(`/cloud-tasks-api/tasks?${p.toString()}`).then(m=>m.json()).then(m=>{r.value=m,i=!1})})};s(o);let a=setInterval(()=>s(o),3e3);F(function(){setTimeout(()=>s(o))});const l=function(){document.visibilityState==="visible"?(s(o),clearInterval(a),a=setInterval(()=>s(o),3e3)):document.visibilityState==="hidden"&&clearInterval(a)};document.addEventListener("visibilitychange",l),O(()=>{clearInterval(a),document.removeEventListener("visibilitychange",l),i=!1})})}const N={props:{status:String,classes:{type:Array,default:[]}},setup(o){function n(t){return t.charAt(0).toUpperCase()+t.slice(1)}return(t,i)=>(u(),_("span",{class:y(["px-2 inline-flex text-xs leading-5 font-semibold rounded-full",[`task-${o.status}`,...o.classes]])},d(n(o.status)),3))}},Le={},Ve=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},[e("svg",{class:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"indigo","stroke-width":"4"}),e("path",{class:"opacity-75",fill:"indigo",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})])])],-1),Me=[Ve];function Ne(o,n){return u(),_("tbody",null,Me)}var Pe=C(Le,[["render",Ne]]);const Be=e("label",{for:"queue",class:"block mb-2 font-medium"},"Queue",-1),De=["onKeyup"],Ee=e("label",{for:"status",class:"block mb-2 mt-6 font-medium"},"Status",-1),Fe=W('',6),Oe=[Fe],je={props:{focus:String},setup(o){const n=o,t=j(),i=V(),s=x(!1),a=x(null),l=x(null);function r(){t.push({name:i.name,query:q(q({},a.value.value?{queue:a.value.value}:{}),l.value?{status:l.value}:{})})}function c(p){p===""&&r()}return H(()=>{setTimeout(()=>s.value=!0),n.focus==="queue"&&a.value.focus()}),(p,m)=>(u(),_("div",{class:y(["w-[300px] fixed transition-transform right-0 top-0 p-6 px-6 shadow-2xl h-screen bg-white",{"translate-x-[300px]":s.value===!1}])},[Be,e("input",{type:"text",name:"queue",id:"queue",ref_key:"queue",ref:a,class:"bg-white py-2 px-3 w-full rounded border",onKeyup:[z(r,["enter"]),m[0]||(m[0]=v=>c(v.target.value))]},null,40,De),Ee,Q(e("select",{name:"status",id:"status","onUpdate:modelValue":m[1]||(m[1]=v=>l.value=v),class:"bg-white py-2 px-3 w-full rounded border"},Oe,512),[[K,l.value]]),e("button",{class:"bg-indigo-500 w-full mt-4 text-indigo-100 rounded py-2",onClick:r}," Apply Filter (or Press Enter) ")],2))}};const He={class:"text-4xl mb-2"},ze={class:"text-lg"},Qe={class:"flex flex-row mt-6"},Ke={class:"flex-1"},We={class:"align-middle"},Ge={class:"shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"},Je={class:"table-fixed divide-y divide-gray-200 w-full"},Xe={class:"bg-gray-50"},Ye=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[50px]"}," # ",-1),Ze=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider max-w-xl w-[300px]"}," Name ",-1),et=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[100px]"}," Status ",-1),tt=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px] text-center"}," Attempts ",-1),st=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[200px]"}," Created ",-1),ot={scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"},nt=k(" Queue "),at={class:"inline relative"},lt=e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"},null,-1),rt=[lt],it=e("th",{scope:"col",class:"relative px-6 py-3"},[e("span",{class:"sr-only"},"Edit")],-1),ct={key:1},ut=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},"No results.")],-1),dt=[ut],pt={class:"bg-white divide-y divide-gray-200"},_t=["onClick"],ht={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-900"},mt={class:"px-6 py-4 whitespace-nowrap text-ellipsis text-sm text-gray-900"},ft={class:"px-6 py-4 whitespace-nowrap"},xt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center"},vt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},yt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},gt=e("td",{class:"px-6 py-4 whitespace-nowrap text-right text-sm font-medium"},[e("a",{href:"#",class:"text-indigo-600 hover:text-indigo-900"},"View")],-1),A={props:{title:String,description:String,tasks:Array},setup(o){const n=o,t=x([]),i=x([]),s=x({visible:!1,focus:null});function a(l){t.value.push(l.id),setTimeout(()=>{t.value.splice(t.value.indexOf(l.id),1)},1e3)}return G(()=>n.tasks,(l,r)=>{var c;if(!!r){i.value=[],r.map((p,m)=>{i[p.id]=m});for(const p of l)(i[p.id]===void 0||((c=r[i[p.id]])==null?void 0:c.status)!==p.status)&&a(p)}}),(l,r)=>(u(),_(g,null,[e("h1",He,d(o.title),1),e("p",ze,d(o.description),1),e("div",Qe,[e("div",Ke,[e("div",We,[e("div",Ge,[e("table",Je,[e("thead",Xe,[e("tr",null,[Ye,Ze,et,tt,st,e("th",ot,[nt,e("div",at,[(u(),_("svg",{xmlns:"http://www.w3.org/2000/svg",class:"h-4 w-4 inline transition-transform hover:scale-[1.1] cursor-pointer",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",onClick:r[0]||(r[0]=()=>{s.value.visible=!s.value.visible,s.value.focus=s.value.visible?"queue":null})},rt))])]),it])]),o.tasks===null?(u(),$(Pe,{key:0})):b("",!0),o.tasks&&o.tasks.length===0?(u(),_("tbody",ct,dt)):b("",!0),e("tbody",pt,[(u(!0),_(g,null,M(o.tasks,c=>(u(),_("tr",{class:y(["cursor-pointer hover:bg-indigo-100/10 transition-colors",{"bg-blue-300/30":t.value.includes(c.id)}]),onClick:p=>l.$router.push({name:`${l.$route.name}-task`,params:{uuid:c.uuid}})},[e("td",ht,d(c.id),1),e("td",mt,d(c.name.substring(0,30))+d(c.name.length>30?"...":""),1),e("td",ft,[h(N,{status:c.status},null,8,["status"])]),e("td",xt,d(c.attempts),1),e("td",vt,d(c.created),1),e("td",yt,d(c.queue),1),gt],10,_t))),256))])])])])])]),s.value.visible?(u(),$(je,{key:0,visible:s.value.visible,focus:s.value.focus},null,8,["visible","focus"])):b("",!0)],64))}},bt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"recent"}),(t,i)=>(u(),$(A,{title:"Recent tasks",description:"Tasks that have been added or processed in the queue recently.",tasks:n.value},null,8,["tasks"]))}},kt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{status:"queued"}),(t,i)=>(u(),$(A,{title:"Queued tasks",description:"Tasks that have been added to the queue recently.",tasks:n.value},null,8,["tasks"]))}},wt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"failed"}),(t,i)=>(u(),$(A,{title:"Failed tasks",description:"Tasks that permanently failed after they have reached their max number of attempts.",tasks:n.value},null,8,["tasks"]))}};const $t={class:"absolute flex items-center justify-center w-2 h-2 bg-gray-200 rounded-full -left-1 ring-1 mt-3 ring-white"},Ct={props:{status:String,classes:{type:Array,default:[]}},setup(o){return(n,t)=>(u(),_("span",$t))}};var qt=C(Ct,[["__scopeId","data-v-35155177"]]);const I=o=>(J("data-v-9b7af9e2"),o=o(),X(),o),Tt={class:"text-4xl mb-2"},St={class:"flex"},At={class:"basis-[400px] shrink-0 pr-6 w-2/12"},It={class:"flex-initial sticky ml-4 mt-12"},Rt={class:"relative border-l border-gray-200 dark:border-gray-700"},Ut={class:"text-gray-900"},Lt={key:0,class:"bg-blue-100 text-blue-800 text-xs font-medium mr-2 inline-block mb-1 px-1.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800"},Vt={key:0},Mt={class:"bg-gray-200 text-gray-800 text-xs font-medium mr-2 inline-block mb-1 px-1.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800"},Nt={class:"block mb-2 mt-2 text-xs text-black/70 font-normal leading-none"},Pt={class:"cursor-default"},Bt={class:"basis-auto overflow-x-auto pr-12"},Dt=I(()=>e("h2",{class:"text-2xl"},"Task Exception",-1)),Et={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Ft={key:1,class:"mt-12"},Ot=I(()=>e("h2",{class:"text-2xl"},"Task Payload",-1)),jt={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Ht=I(()=>e("div",{class:"basis-[250px] shrink-0 px-6"},[e("h2",{class:"text-3xl"},"Actions"),e("button",{class:"bg-gray-200 text-black/20 cursor-not-allowed mt-4 w-full rounded px-4 py-2"}," Retry "),e("span",{class:"text-xs text-gray-800 mt-2 inline-block"},"Retrying tasks is not available yet.")],-1)),zt={setup(o){const n=V(),t=x({id:null,status:"loading"});fetch(`/cloud-tasks-api/task/${n.params.uuid}`).then(s=>s.json()).then(s=>t.value=s);const i={scheduled:"Scheduled",queued:"Added to the queue",running:"Running",successful:"Successful",error:"An error occurred",failed:"Failed permanently"};return(s,a)=>{const l=w("Popper");return u(),_(g,null,[e("h1",Tt,"Task #"+d(t.value.id),1),h(N,{status:t.value.status,classes:["text-sm"]},null,8,["status"]),e("div",St,[e("div",At,[e("div",It,[e("ol",Rt,[(u(!0),_(g,null,M(t.value.events,(r,c)=>(u(),_("li",{class:y(["ml-10 pt-1 mb-6",[`event-${r.status}`]])},[h(qt,{status:r.status},null,8,["status"]),e("h3",Ut,[k(d(i[r.status]||r.status)+" ",1),e("div",null,[r.queue?(u(),_("span",Lt,d(t.value.queue),1)):b("",!0)]),r.scheduled_at?(u(),_("div",Vt,[e("span",Mt," Scheduled: "+d(r.scheduled_at)+" (UTC) ",1)])):b("",!0)]),h(l,{content:r.datetime,hover:!0,arrow:!0,placement:"right"},{default:f(()=>[e("time",Nt,[e("span",Pt,d(r.diff),1)])]),_:2},1032,["content"])],2))),256))])])]),e("div",Bt,[t.value.exception?(u(),_(g,{key:0},[Dt,e("pre",Et,d(t.value.exception),1)],64)):b("",!0),t.value.payload?(u(),_("div",Ft,[Ot,e("pre",jt,d(t.value.payload),1)])):b("",!0)]),Ht])],64)}}};var R=C(zt,[["__scopeId","data-v-9b7af9e2"]]);const Qt=[{name:"home",path:"/",component:Ue},{name:"recent",path:"/recent",component:bt,meta:{route:"recent"}},{name:"recent-task",path:"/recent/:uuid",component:R,meta:{route:"recent"}},{name:"queued",path:"/queued",component:kt,meta:{route:"queued"}},{name:"queued-task",path:"/queued/:uuid",component:R,meta:{route:"queued"}},{name:"failed",path:"/failed",component:wt,meta:{route:"failed"}},{name:"failed-task",path:"/failed/:uuid",component:R,meta:{route:"failed"}}];let P=null;"CloudTasks"in window&&(P=`/${window.CloudTasks.path}`);const Kt=Y({history:Z(P),routes:Qt});ee(me).use(Kt).component("Popper",te).mount("#app"); diff --git a/dashboard/dist/assets/index.1ecbaa60.css b/dashboard/dist/assets/index.4b880b6a.css similarity index 88% rename from dashboard/dist/assets/index.1ecbaa60.css rename to dashboard/dist/assets/index.4b880b6a.css index d5756ff..322a406 100644 --- a/dashboard/dist/assets/index.1ecbaa60.css +++ b/dashboard/dist/assets/index.4b880b6a.css @@ -1 +1 @@ -.router-link-active{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity));font-weight:700;--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}:root{--popper-theme-background-color: #333333;--popper-theme-background-color-hover: #333333;--popper-theme-text-color: #ffffff;--popper-theme-border-width: 0px;--popper-theme-border-style: solid;--popper-theme-border-radius: 6px;--popper-theme-padding: 4px;--popper-theme-box-shadow: 0 6px 30px -6px rgba(0, 0, 0, .25)}*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:before,:after{--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:-webkit-sticky;position:sticky}.right-0{right:0}.top-0{top:0}.-left-1{left:-.25rem}.mb-4{margin-bottom:1rem}.mt-8{margin-top:2rem}.mb-2{margin-bottom:.5rem}.mt-6{margin-top:1.5rem}.mt-4{margin-top:1rem}.mt-3{margin-top:.75rem}.-ml-1{margin-left:-.25rem}.mr-3{margin-right:.75rem}.ml-4{margin-left:1rem}.mt-12{margin-top:3rem}.ml-10{margin-left:2.5rem}.mb-6{margin-bottom:1.5rem}.mr-2{margin-right:.5rem}.mb-1{margin-bottom:.25rem}.mt-2{margin-top:.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-screen{height:100vh}.h-2{height:.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.min-h-screen{min-height:100vh}.w-\[250px\]{width:250px}.w-\[300px\]{width:300px}.w-full{width:100%}.w-2{width:.5rem}.w-\[50px\]{width:50px}.w-\[100px\]{width:100px}.w-\[150px\]{width:150px}.w-\[200px\]{width:200px}.w-4{width:1rem}.w-5{width:1.25rem}.w-2\/12{width:16.666667%}.max-w-\[calc\(100\%-250px\)\]{max-width:calc(100% - 250px)}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-initial{flex:0 1 auto}.shrink-0{flex-shrink:0}.basis-auto{flex-basis:auto}.basis-\[400px\]{flex-basis:400px}.basis-\[250px\]{flex-basis:250px}.table-fixed{table-layout:fixed}.translate-x-\[300px\]{--tw-translate-x: 300px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@-webkit-keyframes spin{to{transform:rotate(360deg)}}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded-lg{border-radius:.5rem}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-indigo-100{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity))}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-blue-300\/30{background-color:#93c5fd4d}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity))}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity))}.bg-red-100\/50{background-color:#fee2e280}.bg-white\/90{background-color:#ffffffe6}.p-6{padding:1.5rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.py-0{padding-top:0;padding-bottom:0}.px-4{padding-left:1rem;padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pr-12{padding-right:3rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-2xl{font-size:1.5rem;line-height:2rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.tracking-wider{letter-spacing:.05em}.text-indigo-900{--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-indigo-100{--tw-text-opacity: 1;color:rgb(224 231 255 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity: 1;color:rgb(79 70 229 / var(--tw-text-opacity))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity))}.text-red-600\/50{color:#dc262680}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}.text-black\/70{color:#000000b3}.text-black\/20{color:#0003}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-white{--tw-ring-opacity: 1;--tw-ring-color: rgb(255 255 255 / var(--tw-ring-opacity))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,fill,stroke,-webkit-text-decoration-color;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,-webkit-text-decoration-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:scale-\[1\.1\]:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-indigo-100\/10:hover{background-color:#e0e7ff1a}.hover\:text-indigo-900:hover{--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}@media (prefers-color-scheme: dark){.dark\:border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.dark\:bg-blue-200{--tw-bg-opacity: 1;background-color:rgb(191 219 254 / var(--tw-bg-opacity))}.dark\:text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}}@media (min-width: 640px){.sm\:rounded-lg{border-radius:.5rem}}.task-error{background-color:#fee2e280;color:#dc262680}.task-successful{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.task-failed{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity))}.task-queued{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.task-running{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}.task-successful[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.task-failed[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(127 29 29 / var(--tw-bg-opacity))}.task-error[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity))}.task-queued[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity))}.task-running[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.no-scroll[data-v-5e0e697d]::-webkit-scrollbar{display:none} +.router-link-active{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity));font-weight:700;--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}:root{--popper-theme-background-color: #333333;--popper-theme-background-color-hover: #333333;--popper-theme-text-color: #ffffff;--popper-theme-border-width: 0px;--popper-theme-border-style: solid;--popper-theme-border-radius: 6px;--popper-theme-padding: 4px;--popper-theme-box-shadow: 0 6px 30px -6px rgba(0, 0, 0, .25)}*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:before,:after{--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:-webkit-sticky;position:sticky}.right-0{right:0}.top-0{top:0}.-left-1{left:-.25rem}.mb-4{margin-bottom:1rem}.mt-8{margin-top:2rem}.mb-2{margin-bottom:.5rem}.mt-6{margin-top:1.5rem}.mt-4{margin-top:1rem}.mt-3{margin-top:.75rem}.-ml-1{margin-left:-.25rem}.mr-3{margin-right:.75rem}.ml-4{margin-left:1rem}.mt-12{margin-top:3rem}.ml-10{margin-left:2.5rem}.mb-6{margin-bottom:1.5rem}.mr-2{margin-right:.5rem}.mb-1{margin-bottom:.25rem}.mt-2{margin-top:.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-screen{height:100vh}.h-2{height:.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.min-h-screen{min-height:100vh}.w-\[250px\]{width:250px}.w-\[300px\]{width:300px}.w-full{width:100%}.w-2{width:.5rem}.w-\[50px\]{width:50px}.w-\[100px\]{width:100px}.w-\[150px\]{width:150px}.w-\[200px\]{width:200px}.w-4{width:1rem}.w-5{width:1.25rem}.w-2\/12{width:16.666667%}.max-w-\[calc\(100\%-250px\)\]{max-width:calc(100% - 250px)}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-initial{flex:0 1 auto}.shrink-0{flex-shrink:0}.basis-auto{flex-basis:auto}.basis-\[400px\]{flex-basis:400px}.basis-\[250px\]{flex-basis:250px}.table-fixed{table-layout:fixed}.translate-x-\[300px\]{--tw-translate-x: 300px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@-webkit-keyframes spin{to{transform:rotate(360deg)}}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded-lg{border-radius:.5rem}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-indigo-100{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity))}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-blue-300\/30{background-color:#93c5fd4d}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity))}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity))}.bg-red-100\/50{background-color:#fee2e280}.bg-white\/90{background-color:#ffffffe6}.p-6{padding:1.5rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.py-0{padding-top:0;padding-bottom:0}.px-4{padding-left:1rem;padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pr-12{padding-right:3rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-2xl{font-size:1.5rem;line-height:2rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.tracking-wider{letter-spacing:.05em}.text-indigo-900{--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-indigo-100{--tw-text-opacity: 1;color:rgb(224 231 255 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity: 1;color:rgb(79 70 229 / var(--tw-text-opacity))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity))}.text-red-600\/50{color:#dc262680}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity))}.text-black\/70{color:#000000b3}.text-black\/20{color:#0003}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-white{--tw-ring-opacity: 1;--tw-ring-color: rgb(255 255 255 / var(--tw-ring-opacity))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,fill,stroke,-webkit-text-decoration-color;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,-webkit-text-decoration-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:scale-\[1\.1\]:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-indigo-100\/10:hover{background-color:#e0e7ff1a}.hover\:text-indigo-900:hover{--tw-text-opacity: 1;color:rgb(49 46 129 / var(--tw-text-opacity))}@media (prefers-color-scheme: dark){.dark\:border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.dark\:bg-blue-200{--tw-bg-opacity: 1;background-color:rgb(191 219 254 / var(--tw-bg-opacity))}.dark\:text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}}@media (min-width: 640px){.sm\:rounded-lg{border-radius:.5rem}}.task-error{background-color:#fee2e280;color:#dc262680}.task-queued,.task-scheduled{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.task-successful{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.task-failed{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity))}.task-queued{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.task-running{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}.task-successful[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity))}.task-failed[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(127 29 29 / var(--tw-bg-opacity))}.task-error[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity))}.task-queued[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity))}.task-running[data-v-35155177]{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.no-scroll[data-v-9b7af9e2]::-webkit-scrollbar{display:none} diff --git a/dashboard/dist/assets/index.643ccf47.js b/dashboard/dist/assets/index.643ccf47.js deleted file mode 100644 index e0408f9..0000000 --- a/dashboard/dist/assets/index.643ccf47.js +++ /dev/null @@ -1 +0,0 @@ -var B=Object.defineProperty;var L=Object.getOwnPropertySymbols;var D=Object.prototype.hasOwnProperty,E=Object.prototype.propertyIsEnumerable;var U=(o,n,t)=>n in o?B(o,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[n]=t,q=(o,n)=>{for(var t in n||(n={}))D.call(n,t)&&U(o,t,n[t]);if(L)for(var t of L(n))E.call(n,t)&&U(o,t,n[t]);return o};var T=(o,n,t)=>new Promise((r,s)=>{var a=c=>{try{i(t.next(c))}catch(u){s(u)}},l=c=>{try{i(t.throw(c))}catch(u){s(u)}},i=c=>c.done?r(c.value):Promise.resolve(c.value).then(a,l);i((t=t.apply(o,n)).next())});import{r as k,o as d,c as _,a as h,w as f,n as v,F as y,b,d as e,e as x,t as p,f as F,g as O,u as j,h as V,i as H,j as z,k as Q,v as K,l as W,m as G,p as $,q as w,s as M,x as J,y as X,z as Y,A as Z,B as ee,C as te}from"./vendor.f52c9be3.js";const se=function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const a of s)if(a.type==="childList")for(const l of a.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&r(l)}).observe(document,{childList:!0,subtree:!0});function t(s){const a={};return s.integrity&&(a.integrity=s.integrity),s.referrerpolicy&&(a.referrerPolicy=s.referrerpolicy),s.crossorigin==="use-credentials"?a.credentials="include":s.crossorigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(s){if(s.ep)return;s.ep=!0;const a=t(s);fetch(s.href,a)}};se();var C=(o,n)=>{const t=o.__vccOpts||o;for(const[r,s]of n)t[r]=s;return t};const oe={},ne=b("Dashboard "),ae=b("Recent "),le=b("Queued "),re=b("Failed ");function ie(o,n){var r,s,a,l,i,c,u,m,g;const t=k("router-link");return d(),_(y,null,[h(t,{to:{name:"home"},class:"block p-4 rounded mb-2 cursor-pointer"},{default:f(()=>[ne]),_:1}),h(t,{to:{name:"recent"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((a=(s=(r=o.$route)==null?void 0:r.matched[0])==null?void 0:s.meta)==null?void 0:a.route)==="recent"}])},{default:f(()=>[ae]),_:1},8,["class"]),h(t,{to:{name:"queued"},class:v(["block p-4 rounded mb-2 cursor-pointer",{"router-link-active":((c=(i=(l=o.$route)==null?void 0:l.matched[0])==null?void 0:i.meta)==null?void 0:c.route)==="queued"}])},{default:f(()=>[le]),_:1},8,["class"]),h(t,{to:{name:"failed"},class:v(["block p-4 rounded mb-2",{"router-link-active":((g=(m=(u=o.$route)==null?void 0:u.matched[0])==null?void 0:m.meta)==null?void 0:g.route)==="failed"}])},{default:f(()=>[re]),_:1},8,["class"])],64)}var ce=C(oe,[["render",ie]]);const ue={components:{Menu:ce}},de={class:"flex"},pe={class:"basis-auto w-[250px] shrink-0 bg-white p-6 min-h-screen"},_e={class:"flex-1 max-w-[calc(100%-250px)] p-6"};function he(o,n,t,r,s,a){const l=k("Menu"),i=k("router-view");return d(),_("div",de,[e("aside",pe,[h(l)]),e("div",_e,[h(i)])])}var me=C(ue,[["render",he]]);const fe=e("h3",{class:"text-3xl mb-4"},"All tasks",-1),xe={class:"grid grid-cols-3 gap-4"},ve=["textContent"],ye=e("span",{class:"text-gray-600"},"this minute",-1),ge=["textContent"],be=e("span",{class:"text-gray-600"},"this hour",-1),we=["textContent"],ke=e("span",{class:"text-gray-600"},"today",-1),$e=e("h3",{class:"text-3xl mb-4 mt-8"},"Failed tasks",-1),Ce={class:"grid grid-cols-3 gap-4"},qe=["textContent"],Te=e("span",{class:"text-gray-600"},"this minute",-1),Se=["textContent"],Ae=e("span",{class:"text-gray-600"},"this hour",-1),Ie=["textContent"],Re=e("span",{class:"text-gray-600"},"today",-1),Le={setup(o){const n=x({recent:{this_minute:"...",this_hour:"...",today:"..."},failed:{this_minute:"...",this_hour:"...",today:"..."}});return fetch("http://github.com/service/http://localhost:8000/cloud-tasks-api/dashboard").then(t=>t.json()).then(t=>n.value=t),(t,r)=>{const s=k("router-link");return d(),_(y,null,[fe,e("div",xe,[h(s,{to:{name:"recent",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_minute)},null,8,ve),ye]}),_:1},8,["to"]),h(s,{to:{name:"recent",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_hour)},null,8,ge),be]}),_:1},8,["to"]),h(s,{to:{name:"recent"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.recent)==null?void 0:l.this_day)},null,8,we),ke]}),_:1})]),$e,e("div",Ce,[h(s,{to:{name:"failed",query:{time:`${new Date().getUTCHours()}:${new Date().getUTCMinutes()}`}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_minute)},null,8,qe),Te]}),_:1},8,["to"]),h(s,{to:{name:"failed",query:{hour:new Date().getUTCHours()}},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_hour)},null,8,Se),Ae]}),_:1},8,["to"]),h(s,{to:{name:"failed"},class:"bg-white rounded-lg p-6"},{default:f(()=>{var a,l;return[e("span",{class:"block text-4xl",textContent:p((l=(a=n.value)==null?void 0:a.failed)==null?void 0:l.this_day)},null,8,Ie),Re]}),_:1})])],64)}}};function S(t){return T(this,arguments,function*(o,n={}){const r=function(l){return T(this,null,function*(){const i=new URL(window.location.href),c=new URLSearchParams(i.search);for(const[u,m]of Object.entries(n))c.append(u,m);fetch(`http://localhost:8000/cloud-tasks-api/tasks?${c.toString()}&test=oke`).then(u=>u.json()).then(u=>{l.value=u})})};r(o);let s=setInterval(()=>r(o),3e3);F(function(){setTimeout(()=>r(o))});const a=function(){document.visibilityState==="visible"?(r(o),clearInterval(s),s=setInterval(()=>r(o),3e3)):document.visibilityState==="hidden"&&clearInterval(s)};document.addEventListener("visibilitychange",a),O(()=>{clearInterval(s),document.removeEventListener("visibilitychange",a)})})}const N={props:{status:String,classes:{type:Array,default:[]}},setup(o){function n(t){return t.charAt(0).toUpperCase()+t.slice(1)}return(t,r)=>(d(),_("span",{class:v(["px-2 inline-flex text-xs leading-5 font-semibold rounded-full",[`task-${o.status}`,...o.classes]])},p(n(o.status)),3))}},Ue={},Ve=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},[e("svg",{class:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"indigo","stroke-width":"4"}),e("path",{class:"opacity-75",fill:"indigo",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})])])],-1),Me=[Ve];function Ne(o,n){return d(),_("tbody",null,Me)}var Pe=C(Ue,[["render",Ne]]);const Be=e("label",{for:"queue",class:"block mb-2 font-medium"},"Queue",-1),De=["onKeyup"],Ee=e("label",{for:"status",class:"block mb-2 mt-6 font-medium"},"Status",-1),Fe=W('',6),Oe=[Fe],je={props:{focus:String},setup(o){const n=o,t=j(),r=V(),s=x(!1),a=x(null),l=x(null);function i(){t.push({name:r.name,query:q(q({},a.value.value?{queue:a.value.value}:{}),l.value?{status:l.value}:{})})}function c(u){u===""&&i()}return H(()=>{setTimeout(()=>s.value=!0),n.focus==="queue"&&a.value.focus()}),(u,m)=>(d(),_("div",{class:v(["w-[300px] fixed transition-transform right-0 top-0 p-6 px-6 shadow-2xl h-screen bg-white",{"translate-x-[300px]":s.value===!1}])},[Be,e("input",{type:"text",name:"queue",id:"queue",ref_key:"queue",ref:a,class:"bg-white py-2 px-3 w-full rounded border",onKeyup:[z(i,["enter"]),m[0]||(m[0]=g=>c(g.target.value))]},null,40,De),Ee,Q(e("select",{name:"status",id:"status","onUpdate:modelValue":m[1]||(m[1]=g=>l.value=g),class:"bg-white py-2 px-3 w-full rounded border"},Oe,512),[[K,l.value]]),e("button",{class:"bg-indigo-500 w-full mt-4 text-indigo-100 rounded py-2",onClick:i}," Apply Filter (or Press Enter) ")],2))}};const He={class:"text-4xl mb-2"},ze={class:"text-lg"},Qe={class:"flex flex-row mt-6"},Ke={class:"flex-1"},We={class:"align-middle"},Ge={class:"shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"},Je={class:"table-fixed divide-y divide-gray-200 w-full"},Xe={class:"bg-gray-50"},Ye=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[50px]"}," # ",-1),Ze=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider max-w-xl w-[300px]"}," Name ",-1),et=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[100px]"}," Status ",-1),tt=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px] text-center"}," Attempts ",-1),st=e("th",{scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-[200px]"}," Created ",-1),ot={scope:"col",class:"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"},nt=b(" Queue "),at={class:"inline relative"},lt=e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"},null,-1),rt=[lt],it=e("th",{scope:"col",class:"relative px-6 py-3"},[e("span",{class:"sr-only"},"Edit")],-1),ct={key:1},ut=e("tr",null,[e("td",{colspan:"7",class:"px-6 py-4 bg-white"},"No results.")],-1),dt=[ut],pt={class:"bg-white divide-y divide-gray-200"},_t=["onClick"],ht={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-900"},mt={class:"px-6 py-4 whitespace-nowrap text-ellipsis text-sm text-gray-900"},ft={class:"px-6 py-4 whitespace-nowrap"},xt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center"},vt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},yt={class:"px-6 py-4 whitespace-nowrap text-sm text-gray-500"},gt=e("td",{class:"px-6 py-4 whitespace-nowrap text-right text-sm font-medium"},[e("a",{href:"#",class:"text-indigo-600 hover:text-indigo-900"},"View")],-1),A={props:{title:String,description:String,tasks:Array},setup(o){const n=o,t=x([]),r=x([]),s=x({visible:!1,focus:null});function a(l){t.value.push(l.id),setTimeout(()=>{t.value.splice(t.value.indexOf(l.id),1)},1e3)}return G(()=>n.tasks,(l,i)=>{var c;if(!!i){r.value=[],i.map((u,m)=>{r[u.id]=m});for(const u of l)(r[u.id]===void 0||((c=i[r[u.id]])==null?void 0:c.status)!==u.status)&&a(u)}}),(l,i)=>(d(),_(y,null,[e("h1",He,p(o.title),1),e("p",ze,p(o.description),1),e("div",Qe,[e("div",Ke,[e("div",We,[e("div",Ge,[e("table",Je,[e("thead",Xe,[e("tr",null,[Ye,Ze,et,tt,st,e("th",ot,[nt,e("div",at,[(d(),_("svg",{xmlns:"http://www.w3.org/2000/svg",class:"h-4 w-4 inline transition-transform hover:scale-[1.1] cursor-pointer",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",onClick:i[0]||(i[0]=()=>{s.value.visible=!s.value.visible,s.value.focus=s.value.visible?"queue":null})},rt))])]),it])]),o.tasks===null?(d(),$(Pe,{key:0})):w("",!0),o.tasks&&o.tasks.length===0?(d(),_("tbody",ct,dt)):w("",!0),e("tbody",pt,[(d(!0),_(y,null,M(o.tasks,c=>(d(),_("tr",{class:v(["cursor-pointer hover:bg-indigo-100/10 transition-colors",{"bg-blue-300/30":t.value.includes(c.id)}]),onClick:u=>l.$router.push({name:`${l.$route.name}-task`,params:{uuid:c.uuid}})},[e("td",ht,p(c.id),1),e("td",mt,p(c.name.substring(0,30))+p(c.name.length>30?"...":""),1),e("td",ft,[h(N,{status:c.status},null,8,["status"])]),e("td",xt,p(c.attempts),1),e("td",vt,p(c.created),1),e("td",yt,p(c.queue),1),gt],10,_t))),256))])])])])])]),s.value.visible?(d(),$(je,{key:0,visible:s.value.visible,focus:s.value.focus},null,8,["visible","focus"])):w("",!0)],64))}},bt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"recent"}),(t,r)=>(d(),$(A,{title:"Recent tasks",description:"Tasks that have been added or processed in the queue recently.",tasks:n.value},null,8,["tasks"]))}},wt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{status:"queued"}),(t,r)=>(d(),$(A,{title:"Queued tasks",description:"Tasks that have been added to the queue recently.",tasks:n.value},null,8,["tasks"]))}},kt={props:{tasks:Array},setup(o){const n=x(null);return S(n,{filter:"failed"}),(t,r)=>(d(),$(A,{title:"Failed tasks",description:"Tasks that permanently failed after they have reached their max number of attempts.",tasks:n.value},null,8,["tasks"]))}};const $t={class:"absolute flex items-center justify-center w-2 h-2 bg-gray-200 rounded-full -left-1 ring-1 mt-3 ring-white"},Ct={props:{status:String,classes:{type:Array,default:[]}},setup(o){return(n,t)=>(d(),_("span",$t))}};var qt=C(Ct,[["__scopeId","data-v-35155177"]]);const I=o=>(J("data-v-5e0e697d"),o=o(),X(),o),Tt={class:"text-4xl mb-2"},St={class:"flex"},At={class:"basis-[400px] shrink-0 pr-6 w-2/12"},It={class:"flex-initial sticky ml-4 mt-12"},Rt={class:"relative border-l border-gray-200 dark:border-gray-700"},Lt={class:"text-gray-900"},Ut={key:0,class:"bg-blue-100 text-blue-800 text-xs font-medium mr-2 inline-block mb-1 px-1.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800"},Vt={class:"block mb-2 mt-2 text-xs text-black/70 font-normal leading-none"},Mt={class:"cursor-default"},Nt={class:"basis-auto overflow-x-auto pr-12"},Pt=I(()=>e("h2",{class:"text-2xl"},"Task Exception",-1)),Bt={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Dt={key:1,class:"mt-12"},Et=I(()=>e("h2",{class:"text-2xl"},"Task Payload",-1)),Ft={class:"text-xs p-8 border border-[#ccc/80] bg-white/90 mt-4 rounded overflow-auto no-scroll"},Ot=I(()=>e("div",{class:"basis-[250px] shrink-0 px-6"},[e("h2",{class:"text-3xl"},"Actions"),e("button",{class:"bg-gray-200 text-black/20 cursor-not-allowed mt-4 w-full rounded px-4 py-2"}," Retry "),e("span",{class:"text-xs text-gray-800 mt-2 inline-block"},"Retrying tasks is not available yet.")],-1)),jt={setup(o){const n=V(),t=x({id:null,status:"loading"});fetch(`http://localhost:8000/cloud-tasks-api/task/${n.params.uuid}`).then(s=>s.json()).then(s=>t.value=s);const r={queued:"Added to the queue",running:"Running",successful:"Successful",error:"An error occurred",failed:"Failed permanently"};return(s,a)=>{const l=k("Popper");return d(),_(y,null,[e("h1",Tt,"Task #"+p(t.value.id),1),h(N,{status:t.value.status,classes:["text-sm"]},null,8,["status"]),e("div",St,[e("div",At,[e("div",It,[e("ol",Rt,[(d(!0),_(y,null,M(t.value.events,(i,c)=>(d(),_("li",{class:v(["ml-10 pt-1 mb-6",[`event-${i.status}`]])},[h(qt,{status:i.status},null,8,["status"]),e("h3",Lt,[b(p(r[i.status]||i.status)+" ",1),e("div",null,[i.queue?(d(),_("span",Ut,p(t.value.queue),1)):w("",!0)])]),h(l,{content:i.datetime,hover:!0,arrow:!0,placement:"right"},{default:f(()=>[e("time",Vt,[e("span",Mt,p(i.diff),1)])]),_:2},1032,["content"])],2))),256))])])]),e("div",Nt,[t.value.exception?(d(),_(y,{key:0},[Pt,e("pre",Bt,p(t.value.exception),1)],64)):w("",!0),t.value.payload?(d(),_("div",Dt,[Et,e("pre",Ft,p(t.value.payload),1)])):w("",!0)]),Ot])],64)}}};var R=C(jt,[["__scopeId","data-v-5e0e697d"]]);const Ht=[{name:"home",path:"/",component:Le},{name:"recent",path:"/recent",component:bt,meta:{route:"recent"}},{name:"recent-task",path:"/recent/:uuid",component:R,meta:{route:"recent"}},{name:"queued",path:"/queued",component:wt,meta:{route:"queued"}},{name:"queued-task",path:"/queued/:uuid",component:R,meta:{route:"queued"}},{name:"failed",path:"/failed",component:kt,meta:{route:"failed"}},{name:"failed-task",path:"/failed/:uuid",component:R,meta:{route:"failed"}}];let P=null;"CloudTasks"in window&&(P=`/${window.CloudTasks.path}`);const zt=Y({history:Z(P),routes:Ht});ee(me).use(zt).component("Popper",te).mount("#app"); diff --git a/dashboard/dist/index.html b/dashboard/dist/index.html index 95bbeb5..810d1cf 100644 --- a/dashboard/dist/index.html +++ b/dashboard/dist/index.html @@ -5,9 +5,9 @@ Vite App - + - +
diff --git a/dashboard/dist/manifest.json b/dashboard/dist/manifest.json index 2955bc7..18bb5f3 100644 --- a/dashboard/dist/manifest.json +++ b/dashboard/dist/manifest.json @@ -1,13 +1,13 @@ { "index.html": { - "file": "assets/index.643ccf47.js", + "file": "assets/index.050f91c9.js", "src": "index.html", "isEntry": true, "imports": [ "_vendor.f52c9be3.js" ], "css": [ - "assets/index.1ecbaa60.css" + "assets/index.4b880b6a.css" ] }, "_vendor.f52c9be3.js": { From b8257c5ce5af6e7add14ec8e3f86e005a7a25802 Mon Sep 17 00:00:00 2001 From: Marick van Tuil Date: Fri, 1 Apr 2022 18:32:13 +0200 Subject: [PATCH 025/258] Replace policy type authentication with password login --- README.md | 7 +- config/cloud-tasks.php | 1 + dashboard/.prettierignore | 6 ++ dashboard/.prettierrc.json | 4 + dashboard/src/App.vue | 5 +- dashboard/src/api.js | 47 +++++++++--- dashboard/src/components/Dashboard.vue | 16 ++-- dashboard/src/components/Failed.vue | 12 ++- dashboard/src/components/Login.vue | 101 +++++++++++++++++++++++++ dashboard/src/components/Queued.vue | 12 ++- dashboard/src/components/Recent.vue | 12 ++- dashboard/src/components/Task.vue | 17 +++-- dashboard/src/main.js | 14 ++++ src/Authenticate.php | 2 +- src/CloudTasks.php | 36 ++++----- src/CloudTasksApiController.php | 12 +++ src/CloudTasksServiceProvider.php | 35 +-------- tests/CloudTasksMonitoringTest.php | 66 ++++++++++++++++ tests/TestCase.php | 2 + 19 files changed, 311 insertions(+), 96 deletions(-) create mode 100644 dashboard/.prettierignore create mode 100644 dashboard/.prettierrc.json create mode 100644 dashboard/src/components/Login.vue diff --git a/README.md b/README.md index 9f9d9c7..76cdb5b 100644 --- a/README.md +++ b/README.md @@ -103,15 +103,10 @@ To make use of it, publish its assets: php artisan vendor:publish --tag=cloud-tasks-assets ``` -We expose a dashboard at the /cloud-tasks URI. By default, you will only be able to access this dashboard in the local environment. However, within your app/Providers/AppServiceProvider.php file, there is an authorization gate definition. This authorization gate controls access to Cloud Tasks in non-local environments. You are free to modify this gate as needed to restrict access to your Cloud Tasks installation: +We expose a dashboard at the /cloud-tasks URI. ```php -Gate::define('viewCloudTasks', function ($user) { - return in_array($user->email, [ - 'me@example.com', - ]); -}); ``` # Authentication diff --git a/config/cloud-tasks.php b/config/cloud-tasks.php index dd26f16..de358e5 100644 --- a/config/cloud-tasks.php +++ b/config/cloud-tasks.php @@ -5,5 +5,6 @@ return [ 'monitor' => [ 'enabled' => env('CLOUD_TASKS_MONITOR_ENABLED', false), + 'password' => env('CLOUD_TASKS_MONITOR_PASSWORD', '$2a$12$q3pRT5jjjjPlTSaGhoy.gupULnK.5lQEiquK5RVaWbGw9nYRy7gwi') // MyPassword1! ], ]; diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore new file mode 100644 index 0000000..85dd8c4 --- /dev/null +++ b/dashboard/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +build +coverage +.vscode +node_modules +.idea diff --git a/dashboard/.prettierrc.json b/dashboard/.prettierrc.json new file mode 100644 index 0000000..b2095be --- /dev/null +++ b/dashboard/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index 7c862e5..eea59d2 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -24,9 +24,6 @@ --popper-theme-box-shadow: 0 6px 30px -6px rgba(0, 0, 0, 0.25); } - diff --git a/dashboard/src/api.js b/dashboard/src/api.js index 50097f1..82df227 100644 --- a/dashboard/src/api.js +++ b/dashboard/src/api.js @@ -1,7 +1,39 @@ import { onUnmounted, watch } from 'vue' import { onBeforeRouteUpdate } from 'vue-router' -export async function fetchTasks(into, query = {}) { +export async function callApi({ + endpoint, + router, + body = null, + method = 'GET', + login = false, +} = {}) { + const response = await fetch( + `${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/${endpoint}`, + { + method, + ...(body ? { body } : {}), + headers: { + ...(!login + ? { + Authorization: `Bearer ${localStorage.getItem( + 'cloud-tasks-token' + )}`, + } + : {}), + }, + } + ) + + if (response.status === 403 && !login) { + localStorage.removeItem('cloud-tasks-token') + router.push({ name: 'login' }) + } + + return login ? await response.text() : await response.json() +} + +export async function fetchTasks(into, query = {}, router) { let paused = false const f = async function (into) { @@ -17,14 +49,11 @@ export async function fetchTasks(into, query = {}) { } paused = true - fetch( - `${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/tasks?${queryParams.toString()}` - ) - .then((response) => response.json()) - .then((response) => { - into.value = response - paused = false - }) + into.value = await callApi({ + endpoint: `tasks?${queryParams.toString()}`, + router, + }) + paused = false } f(into) diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue index 1b18376..7d4fffa 100644 --- a/dashboard/src/components/Dashboard.vue +++ b/dashboard/src/components/Dashboard.vue @@ -1,6 +1,9 @@