├── LICENSE.md ├── README.md ├── composer.json ├── config ├── runtime.php └── vapor-ui.php ├── package.json ├── phpunit.xml.dist ├── public ├── app.css ├── app.js └── mix-manifest.json ├── resources ├── css │ ├── code-editor.css │ ├── transitions.css │ └── vapor-ui.css ├── js │ ├── app.js │ ├── base.js │ ├── components │ │ ├── AsyncButton.vue │ │ ├── BarChart.vue │ │ ├── Loader.vue │ │ ├── Metric.vue │ │ ├── Popover.vue │ │ ├── Search.vue │ │ ├── SearchDetails.vue │ │ ├── SearchEmptyResults.vue │ │ └── icons │ │ │ ├── ArrowDown.vue │ │ │ ├── ArrowUp.vue │ │ │ ├── Calendar.vue │ │ │ ├── ChartBar.vue │ │ │ ├── ChevronRight.vue │ │ │ ├── ClipboardCopy.vue │ │ │ ├── Cloud.vue │ │ │ ├── Collection.vue │ │ │ ├── DesktopComputer.vue │ │ │ ├── DocumentIcon.vue │ │ │ ├── DotsVertical.vue │ │ │ ├── Exclamation.vue │ │ │ ├── Eye.vue │ │ │ ├── Flag.vue │ │ │ ├── InformationCircle.vue │ │ │ ├── Loader.vue │ │ │ ├── Refresh.vue │ │ │ ├── Search.vue │ │ │ ├── Sizable.js │ │ │ ├── Terminal.vue │ │ │ └── XCircle.vue │ ├── mixins │ │ ├── clipboard.js │ │ ├── interactsWithMetrics.js │ │ ├── interactsWithQuantity.js │ │ ├── job.js │ │ └── log.js │ ├── routes.js │ └── screens │ │ ├── jobs │ │ ├── index.vue │ │ ├── metrics.vue │ │ └── show.vue │ │ └── logs │ │ ├── index.vue │ │ └── show.vue └── views │ └── layout.blade.php ├── routes └── web.php ├── src ├── Concerns │ └── ConfiguresVaporUi.php ├── Console │ ├── InstallCommand.php │ └── PublishCommand.php ├── Http │ ├── Controllers │ │ ├── HomeController.php │ │ ├── JobController.php │ │ ├── JobMetricController.php │ │ └── LogController.php │ ├── Middleware │ │ ├── EnsureEnvironmentVariables.php │ │ ├── EnsureUpToDateAssets.php │ │ └── EnsureUserIsAuthorized.php │ └── Requests │ │ ├── JobMetricRequest.php │ │ ├── JobRequest.php │ │ └── LogRequest.php ├── Jobs │ ├── ForgetFailedJob.php │ └── RetryFailedJob.php ├── Repositories │ ├── JobsMetricsRepository.php │ ├── JobsRepository.php │ └── LogsRepository.php ├── Support │ └── Cloud.php ├── ValueObjects │ ├── Job.php │ ├── Log.php │ └── SearchResult.php └── VaporUiServiceProvider.php ├── stubs └── VaporUiServiceProvider.stub ├── tailwind.config.js └── webpack.mix.js /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > This package is deprecated and will not receive Laravel `^11.0` support, as its functionality is largely replaced by Vapor's own dashboard. 2 | 3 | # Laravel Vapor UI 4 | 5 | 6 | Build Status 7 | 8 | 9 | Total Downloads 10 | 11 | 12 | Latest Stable Version 13 | 14 | 15 | License 16 | 17 | 18 | [Laravel Vapor](https://vapor.laravel.com) is an auto-scaling, serverless deployment platform for Laravel, powered by AWS Lambda. Manage your Laravel infrastructure on Vapor and fall in love with the scalability and simplicity of serverless. 19 | 20 | Vapor abstracts the complexity of managing Laravel applications on AWS Lambda, as well as interfacing those applications with SQS queues, databases, Redis clusters, networks, CloudFront CDN, and more. 21 | 22 | This package provides a beautiful dashboard accessible via your Vapor application that allows you to view / search your application's logs and failed queue jobs. 23 | 24 | ![image](https://laravel-blog-assets.s3.amazonaws.com/Pt7UBx57HxsGR5Qlga9cLSoAvkAukMT5BMkcLK9N.png "image") 25 | 26 | ## Official Documentation 27 | 28 | Documentation for Vapor UI can be found in the [Laravel Vapor documentation](https://docs.vapor.build/1.0/introduction.html#installing-the-vapor-ui-dashboard). 29 | 30 | ## Contributing 31 | 32 | Thank you for considering contributing to Vapor UI! You can read the contribution guide [here](.github/CONTRIBUTING.md). 33 | 34 | ## Code of Conduct 35 | 36 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 37 | 38 | ## Security Vulnerabilities 39 | 40 | Please review [our security policy](https://github.com/laravel/vapor-ui/security/policy) on how to report security vulnerabilities. 41 | 42 | ## License 43 | 44 | Laravel Vapor UI is open-sourced software licensed under the [MIT license](LICENSE.md). 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/vapor-ui", 3 | "description": "The Laravel Vapor UI.", 4 | "keywords": ["laravel", "vapor"], 5 | "license": "MIT", 6 | "support": { 7 | "issues": "https://github.com/laravel/vapor-ui/issues", 8 | "source": "https://github.com/laravel/vapor-ui" 9 | }, 10 | "authors": [ 11 | { 12 | "name": "Taylor Otwell", 13 | "email": "taylor@laravel.com" 14 | }, 15 | { 16 | "name": "Nuno Maduro", 17 | "email": "nuno@laravel.com" 18 | } 19 | ], 20 | "require": { 21 | "php": "^7.3|^8.0", 22 | "aws/aws-sdk-php": "^3.148.3", 23 | "laravel/framework": "^8.0|^9.0|^10.0", 24 | "symfony/console": "^5.3|^6.0", 25 | "symfony/yaml": "^5.1.4|^6.0" 26 | }, 27 | "require-dev": { 28 | "orchestra/testbench": "^6.17.1|^7.0|^8.0", 29 | "pestphp/pest": "^1.22.3" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Laravel\\VaporUi\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests/" 39 | } 40 | }, 41 | "extra": { 42 | "branch-alias": { 43 | "dev-master": "1.x-dev" 44 | }, 45 | "laravel": { 46 | "providers": [ 47 | "Laravel\\VaporUi\\VaporUiServiceProvider" 48 | ] 49 | } 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "pestphp/pest-plugin": true 55 | } 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true 59 | } 60 | -------------------------------------------------------------------------------- /config/runtime.php: -------------------------------------------------------------------------------- 1 | env('AWS_ACCESS_KEY_ID'), 8 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 9 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 10 | 11 | // Vapor Project / CloudWatch Log Group 12 | 'project' => env('VAPOR_PROJECT', Cloud::project()), 13 | 'environment' => env('VAPOR_ENVIRONMENT', Cloud::environment()), 14 | 15 | // SQS Queue 16 | 'queue' => [ 17 | 'prefix' => env('SQS_PREFIX'), 18 | 'name' => env('SQS_QUEUE'), 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /config/vapor-ui.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'web', 22 | EnsureUserIsAuthorized::class, 23 | EnsureEnvironmentVariables::class, 24 | EnsureUpToDateAssets::class, 25 | ], 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Vapor UI Domain 30 | |-------------------------------------------------------------------------- 31 | | 32 | | This is the subdomain where Vapor UI will be accessible from. If this 33 | | setting is null, Vapor UI will reside under the same domain as the 34 | | application. Otherwise this value should serve as the subdomain. 35 | | 36 | */ 37 | 38 | 'domain' => env('VAPOR_UI_DOMAIN', null), 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Vapor UI Path 43 | |-------------------------------------------------------------------------- 44 | | 45 | | This is the URI path where Vapor UI will be accessible from. Feel free 46 | | to change the path to anything you like. Note that the URI will not 47 | | affect the paths of its internal API that isn't exposed to users. 48 | | 49 | */ 50 | 51 | 'path' => env('VAPOR_UI_PATH', 'vapor-ui'), 52 | 53 | /* 54 | |-------------------------------------------------------------------------- 55 | | Vapor UI Queues 56 | |-------------------------------------------------------------------------- 57 | | 58 | | Typically, queues that should be monitored will be determined for you 59 | | by Vapor UI. However, you are free to add additional queues to the 60 | | list below in order to monitor queues that Vapor doesn't manage. 61 | | 62 | */ 63 | 64 | 'queues' => [ 65 | 66 | ], 67 | 68 | ]; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "npm run development -- --watch", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "@tailwindcss/ui": "^0.5.0", 14 | "axios": "^0.19", 15 | "chart.js": "^2.9.3", 16 | "cross-env": "^7.0", 17 | "laravel-mix": "^5.0.1", 18 | "moment": "^2.27.0", 19 | "moment-timezone": "^0.5.31", 20 | "postcss-import": "^12.0.1", 21 | "tailwindcss": "^1.6.2", 22 | "vue": "^2.6.11", 23 | "vue-json-pretty": "^1.6.7", 24 | "vue-popperjs": "^2.3.0", 25 | "vue-router": "^3.0.1", 26 | "vue-template-compiler": "^2.5.21" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests/Unit 17 | 18 | 19 | ./tests/Integration 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/app.js": "/app.js?id=44c148984c42d320dccb", 3 | "/app.css": "/app.css?id=fbb793d795cfb9fe09b2" 4 | } 5 | -------------------------------------------------------------------------------- /resources/css/code-editor.css: -------------------------------------------------------------------------------- 1 | .vjs-tree__content { 2 | border-left: 1px dotted rgba(204, 204, 204, 0.28) !important; 3 | } 4 | .vjs-tree__node { 5 | cursor: pointer; 6 | &:hover { 7 | color: #20a0ff; 8 | } 9 | } 10 | .vjs-checkbox { 11 | position: absolute; 12 | left: -30px; 13 | } 14 | .vjs-value__null { 15 | color: #a291f5 !important; 16 | } 17 | .vjs-value__number, 18 | .vjs-value__boolean { 19 | color: #a291f5 !important; 20 | } 21 | .vjs-value__string { 22 | color: #dacb4d !important; 23 | } 24 | 25 | .hljs-keyword, 26 | .hljs-selector-tag, 27 | .hljs-addition { 28 | color: #8bd72f; 29 | } 30 | 31 | .hljs-string, 32 | .hljs-meta .hljs-meta-string, 33 | .hljs-doctag, 34 | .hljs-regexp { 35 | color: #dacb4d; 36 | } 37 | 38 | .hljs-number, 39 | .hljs-literal { 40 | color: #a291f5 !important; 41 | } 42 | -------------------------------------------------------------------------------- /resources/css/transitions.css: -------------------------------------------------------------------------------- 1 | .fade-enter-active, 2 | .fade-leave-active { 3 | transition: opacity 0.5s; 4 | } 5 | .fade-enter, 6 | .fade-leave-to { 7 | opacity: 0; 8 | } 9 | 10 | .list-enter-active, 11 | .list-leave-active, 12 | .list-move { 13 | transition: 50ms cubic-bezier(0.59, 0.12, 0.34, 0.95); 14 | transition-property: opacity, transform; 15 | } 16 | 17 | .list-enter { 18 | opacity: 0; 19 | transform: translateX(50px) scaleY(0.5); 20 | } 21 | 22 | .list-enter-to { 23 | opacity: 1; 24 | transform: translateX(0) scaleY(1); 25 | } 26 | 27 | .list-leave-active { 28 | position: absolute; 29 | } 30 | 31 | .list-leave-to { 32 | opacity: 0; 33 | transform: scaleY(0); 34 | transform-origin: center top; 35 | } 36 | -------------------------------------------------------------------------------- /resources/css/vapor-ui.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | 3 | @import 'tailwindcss/components'; 4 | 5 | @import 'tailwindcss/utilities'; 6 | 7 | @import './code-editor.css'; 8 | 9 | @import './transitions.css'; 10 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Base from './base'; 3 | import moment from 'moment-timezone'; 4 | import Routes from './routes'; 5 | import Vue from 'vue'; 6 | import VueJsonPretty from 'vue-json-pretty'; 7 | import VueRouter from 'vue-router'; 8 | 9 | const token = document.head.querySelector('meta[name="csrf-token"]'); 10 | 11 | if (token) { 12 | axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content; 13 | } 14 | 15 | Vue.use(VueRouter); 16 | 17 | moment.tz.setDefault('utc'); 18 | 19 | const router = new VueRouter({ 20 | routes: Routes, 21 | mode: 'history', 22 | base: '/' + window.VaporUi.path, 23 | }); 24 | 25 | router.beforeEach((to, from, next) => { 26 | to.meta.title = to.meta.createTitle(to.params); 27 | 28 | document.title = 'Vapor UI - ' + to.meta.title; 29 | 30 | next(); 31 | }); 32 | 33 | Vue.component('vue-json-pretty', VueJsonPretty); 34 | 35 | // Components 36 | Vue.component('async-button', require('./components/AsyncButton.vue').default); 37 | Vue.component('bar-chart', require('./components/BarChart.vue').default); 38 | Vue.component('search', require('./components/Search.vue').default); 39 | Vue.component('search-details', require('./components/SearchDetails.vue').default); 40 | Vue.component('search-empty-results', require('./components/SearchEmptyResults.vue').default); 41 | Vue.component('loader', require('./components/Loader.vue').default); 42 | Vue.component('metric', require('./components/Metric.vue').default); 43 | Vue.component('popover', require('./components/Popover.vue').default); 44 | 45 | // Icons 46 | Vue.component('icon-arrow-down', require('./components/icons/ArrowDown.vue').default); 47 | Vue.component('icon-arrow-up', require('./components/icons/ArrowUp.vue').default); 48 | Vue.component('icon-refresh', require('./components/icons/Refresh.vue').default); 49 | Vue.component('icon-search', require('./components/icons/Search.vue').default); 50 | Vue.component('icon-cloud', require('./components/icons/Cloud.vue').default); 51 | Vue.component('icon-collection', require('./components/icons/Collection.vue').default); 52 | Vue.component('icon-exclamation', require('./components/icons/Exclamation.vue').default); 53 | Vue.component('icon-desktop-computer', require('./components/icons/DesktopComputer.vue').default); 54 | Vue.component('icon-dots-vertical', require('./components/icons/DotsVertical.vue').default); 55 | Vue.component('icon-loader', require('./components/icons/Loader.vue').default); 56 | Vue.component('icon-flag', require('./components/icons/Flag.vue').default); 57 | Vue.component('icon-calendar', require('./components/icons/Calendar.vue').default); 58 | Vue.component('icon-clipboard-copy', require('./components/icons/ClipboardCopy.vue').default); 59 | Vue.component('icon-chevron-right', require('./components/icons/ChevronRight.vue').default); 60 | Vue.component('icon-eye', require('./components/icons/Eye.vue').default); 61 | Vue.component('icon-chart-bar', require('./components/icons/ChartBar.vue').default); 62 | Vue.component('icon-terminal', require('./components/icons/Terminal.vue').default); 63 | Vue.component('icon-x-circle', require('./components/icons/XCircle.vue').default); 64 | Vue.component('icon-information-circle', require('./components/icons/InformationCircle.vue').default); 65 | 66 | Vue.mixin(Base); 67 | 68 | new Vue({ 69 | el: '#vapor-ui', 70 | router, 71 | }); 72 | -------------------------------------------------------------------------------- /resources/js/base.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import moment from 'moment-timezone'; 3 | 4 | export default { 5 | methods: { 6 | /** 7 | * Returns an moment instance. 8 | */ 9 | moment() { 10 | return moment; 11 | }, 12 | 13 | /** 14 | * Creates a debounced function that delays invoking a callback. 15 | */ 16 | debouncer: _.debounce((callback) => callback(), 500), 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /resources/js/components/AsyncButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /resources/js/components/BarChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 107 | -------------------------------------------------------------------------------- /resources/js/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 41 | -------------------------------------------------------------------------------- /resources/js/components/Metric.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 130 | -------------------------------------------------------------------------------- /resources/js/components/Popover.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | -------------------------------------------------------------------------------- /resources/js/components/Search.vue: -------------------------------------------------------------------------------- 1 | 165 | 166 | 388 | -------------------------------------------------------------------------------- /resources/js/components/SearchDetails.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 135 | -------------------------------------------------------------------------------- /resources/js/components/SearchEmptyResults.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /resources/js/components/icons/ArrowDown.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /resources/js/components/icons/ArrowUp.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/Calendar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/ChartBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /resources/js/components/icons/ChevronRight.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/ClipboardCopy.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /resources/js/components/icons/Cloud.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /resources/js/components/icons/Collection.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /resources/js/components/icons/DesktopComputer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/DocumentIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/DotsVertical.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /resources/js/components/icons/Exclamation.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /resources/js/components/icons/Eye.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /resources/js/components/icons/Flag.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/InformationCircle.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /resources/js/components/icons/Loader.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 79 | -------------------------------------------------------------------------------- /resources/js/components/icons/Refresh.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/Search.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/Sizable.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | size: { 4 | default: '8', 5 | }, 6 | }, 7 | 8 | computed: { 9 | sizeClass() { 10 | return { 11 | 2: 'h-2 w-2', 12 | 3: 'h-3 w-3', 13 | 4: 'h-4 w-4', 14 | 5: 'h-5 w-5', 15 | 6: 'h-6 w-6', 16 | 8: 'h-8 w-8', 17 | 12: 'h-12 w-12', 18 | 16: 'h-16 w-16', 19 | 20: 'h-20 w-20', 20 | }[this.size]; 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /resources/js/components/icons/Terminal.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/components/icons/XCircle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /resources/js/mixins/clipboard.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | copyToClipboard(href) { 4 | const el = document.createElement('textarea'); 5 | const base = window.location; 6 | el.value = base.protocol + '//' + base.host + href; 7 | el.setAttribute('readonly', ''); 8 | el.style.position = 'absolute'; 9 | el.style.left = '-9999px'; 10 | document.body.appendChild(el); 11 | el.select(); 12 | document.execCommand('copy'); 13 | document.body.removeChild(el); 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /resources/js/mixins/interactsWithMetrics.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | import InteractsWithQuantiy from './interactsWithQuantity'; 4 | 5 | export default { 6 | /** 7 | * The mixin's mixins. 8 | */ 9 | mixins: [InteractsWithQuantiy], 10 | 11 | /** 12 | * The mixin's methods. 13 | */ 14 | methods: { 15 | /** 16 | * Formats the give date to a human readable format. 17 | */ 18 | formatMetricDate(date) { 19 | return moment 20 | .utc(date * 1000, 'x') 21 | .local() 22 | .format('LT') 23 | .replace(/:[0-9]{2}/, ''); 24 | }, 25 | 26 | /** 27 | * Formats the given label to a tooltip title. 28 | */ 29 | formatTooltipTitle([{ label }]) { 30 | return ( 31 | label + 32 | ' - ' + 33 | moment(label, 'LT') 34 | .add(1, 'hours') 35 | .local(true) 36 | .format('LT') 37 | .replace(/:[0-9]{2}/, '') 38 | ); 39 | }, 40 | 41 | /** 42 | * Gets the max value of the given timeseries. 43 | */ 44 | suggestedMax(timeseries) { 45 | const data = timeseries.map((point) => point.value); 46 | 47 | return Math.max(Math.max(...data), 1); 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /resources/js/mixins/interactsWithQuantity.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export default { 4 | /** 5 | * The mixin's methods. 6 | */ 7 | methods: { 8 | /** 9 | * Formats the given quantity to a human readable format. 10 | */ 11 | formatQuantity(quantity) { 12 | const replace = (num, division, unit) => { 13 | return (num / division).toFixed(1).replace(/\.0$/, '') + unit; 14 | }; 15 | 16 | if (quantity >= 1000000) { 17 | return replace(quantity, 1000000, 'M'); 18 | } else if (quantity >= 1000) { 19 | return replace(quantity, 1000, 'K'); 20 | } else if (quantity <= -1000000) { 21 | return replace(quantity, -1000000, 'M'); 22 | } else if (quantity <= -1000) { 23 | return replace(quantity, -1000, 'K'); 24 | } 25 | 26 | return quantity; 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /resources/js/mixins/job.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Returns the log color by the given display name. 5 | */ 6 | jobColor(displayName) { 7 | return 'red'; 8 | }, 9 | 10 | /** 11 | * Returns the queue names. 12 | */ 13 | queues() { 14 | let queues = {}; 15 | 16 | VaporUi.queues.forEach((queue) => { 17 | if (typeof queue == 'object') { 18 | let q = Object.keys(queue)[0] || null; 19 | 20 | if (q) { 21 | queues[q] = q; 22 | return; 23 | } 24 | } 25 | 26 | if (typeof queue == 'string') { 27 | queues[queue] = queue; 28 | return; 29 | } 30 | }); 31 | 32 | return queues; 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /resources/js/mixins/log.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Returns the log types. 5 | */ 6 | logTypes() { 7 | return { 8 | debug: 'Debug', 9 | info: 'Info', 10 | notice: 'Notice', 11 | warning: 'Warning', 12 | alert: 'Alert', 13 | critical: 'Critical', 14 | emergency: 'Emergency', 15 | timeout: 'Timeout', 16 | error: 'Error', 17 | }; 18 | }, 19 | 20 | /** 21 | * Returns the log color by the given type. 22 | */ 23 | logColor(type) { 24 | const compare = (level) => level === type.toLowerCase(); 25 | 26 | if (['info', 'notice'].some(compare)) { 27 | return 'blue'; 28 | } 29 | 30 | if (['warning', 'alert'].some(compare)) { 31 | return 'yellow'; 32 | } 33 | 34 | if (['critical', 'emergency', 'error', 'timeout'].some(compare)) { 35 | return 'red'; 36 | } 37 | 38 | return 'gray'; 39 | }, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /resources/js/routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { path: '/', redirect: '/logs/http' }, 3 | 4 | { 5 | path: '/logs/:group', 6 | name: 'logs-index', 7 | component: require('./screens/logs/index').default, 8 | meta: { 9 | resource: 'logs', 10 | createTitle: ({ group }) => group.toUpperCase() + ' Logs', 11 | }, 12 | }, 13 | 14 | { 15 | path: '/logs/:group/:id', 16 | name: 'logs-show', 17 | component: require('./screens/logs/show').default, 18 | meta: { 19 | resource: 'logs', 20 | createTitle: ({ group }) => group.toUpperCase() + ' Logs - Details', 21 | }, 22 | }, 23 | 24 | { 25 | path: '/jobs/metrics', 26 | name: 'jobs-metrics', 27 | component: require('./screens/jobs/metrics').default, 28 | meta: { 29 | resource: 'jobs', 30 | createTitle: () => 'Jobs Metrics', 31 | }, 32 | }, 33 | 34 | { 35 | path: '/jobs/:group', 36 | name: 'jobs-index', 37 | component: require('./screens/jobs/index').default, 38 | meta: { 39 | resource: 'jobs', 40 | createTitle: ({ group }) => group.toUpperCase() + ' Jobs', 41 | }, 42 | }, 43 | 44 | { 45 | path: '/jobs/:group/:id', 46 | name: 'jobs-show', 47 | component: require('./screens/jobs/show').default, 48 | meta: { 49 | resource: 'jobs', 50 | createTitle: ({ group }) => group.toUpperCase() + ' Jobs - Details', 51 | }, 52 | }, 53 | ]; 54 | -------------------------------------------------------------------------------- /resources/js/screens/jobs/index.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 88 | -------------------------------------------------------------------------------- /resources/js/screens/jobs/metrics.vue: -------------------------------------------------------------------------------- 1 | 129 | 130 | 203 | -------------------------------------------------------------------------------- /resources/js/screens/jobs/show.vue: -------------------------------------------------------------------------------- 1 | 141 | 142 | 190 | -------------------------------------------------------------------------------- /resources/js/screens/logs/index.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 101 | -------------------------------------------------------------------------------- /resources/js/screens/logs/show.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 109 | -------------------------------------------------------------------------------- /resources/views/layout.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Vapor UI - {{ config('vapor-ui.project') }} - {{ config('vapor-ui.environment') }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 | 112 |
113 |
114 | 115 | 116 |
117 | 118 | 119 | 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | group(function () { 11 | Route::get('/api/logs/{group}', [LogController::class, 'index']); 12 | Route::get('/api/logs/{group}/{id}', [LogController::class, 'show']); 13 | 14 | Route::get('/api/jobs/metrics', [JobMetricController::class, 'index']); 15 | 16 | Route::get('/api/jobs/{group}', [JobController::class, 'index']); 17 | Route::get('/api/jobs/{group}/{id}', [JobController::class, 'show']); 18 | 19 | Route::post('/api/jobs/failed/retry/{id}', [JobController::class, 'retry']); 20 | Route::post('/api/jobs/failed/forget/{id}', [JobController::class, 'forget']); 21 | 22 | Route::get('/{view?}', HomeController::class)->where('view', '(.*)')->name('vapor-ui'); 23 | }); 24 | -------------------------------------------------------------------------------- /src/Concerns/ConfiguresVaporUi.php: -------------------------------------------------------------------------------- 1 | $_ENV['AWS_ACCESS_KEY_ID'] ?? null, 23 | 'secret' => $_ENV['AWS_SECRET_ACCESS_KEY'] ?? null, 24 | 'region' => $_ENV['AWS_DEFAULT_REGION'] ?? 'us-east-1', 25 | 'token' => $_ENV['AWS_SESSION_TOKEN'] ?? null, 26 | 'queue' => [ 27 | 'prefix' => $_ENV['SQS_PREFIX'] ?? null, 28 | 'name' => $_ENV['SQS_QUEUE'] ?? null, 29 | ], 30 | ], Config::get('vapor-ui') ?? [])); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | comment('Publishing Vapor UI Service Provider...'); 34 | $this->callSilent('vendor:publish', ['--tag' => 'vapor-ui-provider']); 35 | 36 | $this->comment('Publishing Vapor UI Assets...'); 37 | $this->callSilent('vendor:publish', ['--tag' => 'vapor-ui-assets']); 38 | 39 | $this->registerVaporUiServiceProvider(); 40 | 41 | $this->info('Vapor UI scaffolding installed successfully.'); 42 | } 43 | 44 | /** 45 | * Register the Vapor UI service provider in the application configuration file. 46 | * 47 | * @return void 48 | */ 49 | protected function registerVaporUiServiceProvider() 50 | { 51 | $namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace()); 52 | 53 | $appConfig = file_get_contents(config_path('app.php')); 54 | 55 | if (Str::contains($appConfig, $namespace.'\\Providers\\VaporUiServiceProvider::class')) { 56 | return; 57 | } 58 | 59 | $lineEndingCount = [ 60 | "\r\n" => substr_count($appConfig, "\r\n"), 61 | "\r" => substr_count($appConfig, "\r"), 62 | "\n" => substr_count($appConfig, "\n"), 63 | ]; 64 | 65 | $eol = array_keys($lineEndingCount, max($lineEndingCount))[0]; 66 | 67 | file_put_contents(config_path('app.php'), str_replace( 68 | "{$namespace}\\Providers\RouteServiceProvider::class,".$eol, 69 | "{$namespace}\\Providers\RouteServiceProvider::class,".$eol." {$namespace}\Providers\VaporUiServiceProvider::class,".$eol, 70 | $appConfig 71 | )); 72 | 73 | file_put_contents(app_path('Providers/VaporUiServiceProvider.php'), str_replace( 74 | "namespace App\Providers;", 75 | "namespace {$namespace}\Providers;", 76 | file_get_contents(app_path('Providers/VaporUiServiceProvider.php')) 77 | )); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Console/PublishCommand.php: -------------------------------------------------------------------------------- 1 | call('vendor:publish', [ 33 | '--tag' => 'vapor-ui-assets', 34 | '--force' => true, 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Http/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | config('vapor-ui.path', 'vapor-ui'), 19 | 'queues' => Cloud::queues(), 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/JobController.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 29 | } 30 | 31 | /** 32 | * Gets the job results by the given request filters. 33 | * 34 | * @param string $group 35 | * @return SearchResult 36 | */ 37 | public function index($group, JobRequest $request) 38 | { 39 | return $this->repository->search($group, $request->validated()); 40 | } 41 | 42 | /** 43 | * Gets a job by the given request filters. 44 | * 45 | * @param string $group 46 | * @param string $id 47 | * @return Job|null 48 | */ 49 | public function show($group, $id, JobRequest $request) 50 | { 51 | return $this->repository->get($group, $id, $request->validated()); 52 | } 53 | 54 | /** 55 | * Retry a job by the given id. 56 | * 57 | * @param string $id 58 | * @return void 59 | */ 60 | public function retry($id) 61 | { 62 | dispatch_sync(new RetryFailedJob($id)); 63 | } 64 | 65 | /** 66 | * Forget a job by the given id. 67 | * 68 | * @param string $id 69 | * @return void 70 | */ 71 | public function forget($id) 72 | { 73 | dispatch_sync(new ForgetFailedJob($id)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Http/Controllers/JobMetricController.php: -------------------------------------------------------------------------------- 1 | metrics = $metrics; 26 | } 27 | 28 | /** 29 | * Gets multiple metrics about jobs. 30 | * 31 | * @return array 32 | */ 33 | public function index(JobMetricRequest $request) 34 | { 35 | tap($request->get('queue'), function ($queue) { 36 | if ($queue) { 37 | $this->metrics->resolveQueueUsing(function () use ($queue) { 38 | return $queue; 39 | }); 40 | } 41 | }); 42 | 43 | return [ 44 | 'failed' => [ 45 | 'timeseries' => $this->metrics->failedTimeseries(), 46 | 'pastHour' => $this->metrics->failedSumByHoursAgo(1), 47 | 'averagePast24Hours' => $this->metrics->failedAverageByHoursAgo(25, 1), 48 | ], 49 | 'processed' => [ 50 | 'timeseries' => $this->metrics->processedTimeseries(), 51 | 'pastHour' => $this->metrics->processedSumByHoursAgo(1), 52 | 'averagePast24Hours' => $this->metrics->processedAverageByHoursAgo(25, 1), 53 | ], 54 | 'pending' => $this->metrics->pending(), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Http/Controllers/LogController.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 28 | } 29 | 30 | /** 31 | * Gets the log results by the given request filters. 32 | * 33 | * @param string $group 34 | * @param LogRequest $request 35 | * @return SearchResult 36 | */ 37 | public function index($group, LogRequest $request) 38 | { 39 | return $this->repository->search($group, $request->validated()); 40 | } 41 | 42 | /** 43 | * Gets a log by the given request filters. 44 | * 45 | * @param string $group 46 | * @param string $id 47 | * @param LogRequest $request 48 | * @return Log|null 49 | */ 50 | public function show($group, $id, LogRequest $request) 51 | { 52 | return $this->repository->get($group, $id, $request->validated()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Http/Middleware/EnsureEnvironmentVariables.php: -------------------------------------------------------------------------------- 1 | configs)->each(function ($name) use ($message, $config) { 39 | if (empty(Arr::get($config, $name))) { 40 | throw new RuntimeException(sprintf($message, $name)); 41 | } 42 | }); 43 | 44 | return $next($request); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Middleware/EnsureUpToDateAssets.php: -------------------------------------------------------------------------------- 1 | runningInConsole()) { 23 | $publishedPath = public_path('vendor/vapor-ui/mix-manifest.json'); 24 | 25 | if (! File::exists($publishedPath)) { 26 | throw new RuntimeException('The Vapor UI assets are not published. Please run: `php artisan vapor-ui:install.'); 27 | } 28 | 29 | if (File::get($publishedPath) !== File::get(__DIR__.'/../../../public/mix-manifest.json')) { 30 | throw new RuntimeException('The published Vapor UI assets are not up-to-date with the installed version. Please run: `php artisan vapor-ui:publish`.'); 31 | } 32 | } 33 | 34 | return $next($request); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Http/Middleware/EnsureUserIsAuthorized.php: -------------------------------------------------------------------------------- 1 | environment('local') 22 | || Gate::allows('viewVaporUI', [$request->user()]); 23 | 24 | abort_unless($allowed, 403); 25 | 26 | return $next($request); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Requests/JobMetricRequest.php: -------------------------------------------------------------------------------- 1 | ['string'], 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Http/Requests/JobRequest.php: -------------------------------------------------------------------------------- 1 | ['nullable', 'string'], 18 | 'startTime' => ['required', 'integer'], 19 | 'cursor' => ['nullable', 'string'], 20 | 'queue' => ['string'], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Http/Requests/LogRequest.php: -------------------------------------------------------------------------------- 1 | ['nullable', 'string'], 18 | 'startTime' => ['required', 'integer'], 19 | 'cursor' => ['nullable', 'string'], 20 | 'type' => ['nullable', 'string'], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Jobs/ForgetFailedJob.php: -------------------------------------------------------------------------------- 1 | id = $id; 25 | } 26 | 27 | /** 28 | * Execute the job. 29 | * 30 | * @param FailedJobProviderInterface $provider 31 | * @return void 32 | */ 33 | public function handle(FailedJobProviderInterface $provider) 34 | { 35 | if ($job = $provider->find($this->id)) { 36 | $provider->forget($this->id); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Jobs/RetryFailedJob.php: -------------------------------------------------------------------------------- 1 | id = $id; 27 | } 28 | 29 | /** 30 | * Execute the job. 31 | * 32 | * @param Factory $queueFactory 33 | * @param FailedJobProviderInterface $provider 34 | * @return void 35 | */ 36 | public function handle(Factory $queueFactory, FailedJobProviderInterface $provider) 37 | { 38 | if ($job = $provider->find($this->id)) { 39 | $queueFactory->connection($job->connection)->pushRaw( 40 | $this->resetAttempts($job->payload), $job->queue 41 | ); 42 | 43 | $provider->forget($this->id); 44 | } 45 | } 46 | 47 | /** 48 | * Reset the payload attempts. 49 | * 50 | * Applicable to Redis jobs which store attempts in their payload. 51 | * 52 | * @param string $payload 53 | * @return string 54 | */ 55 | protected function resetAttempts($payload) 56 | { 57 | $payload = json_decode($payload, true); 58 | 59 | if (isset($payload['attempts'])) { 60 | $payload['attempts'] = 0; 61 | } 62 | 63 | $retryUntil = $payload['retryUntil'] ?? $payload['timeoutAt'] ?? null; 64 | 65 | if ($retryUntil) { 66 | $payload['retryUntil'] = CarbonImmutable::now()->addSeconds((int) ceil( 67 | isset($payload['pushedAt']) ? $retryUntil - $payload['pushedAt'] : config('vapor-ui.retry_until', 3600) 68 | ))->getTimestamp(); 69 | } 70 | 71 | return json_encode($payload); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Repositories/JobsMetricsRepository.php: -------------------------------------------------------------------------------- 1 | cloudWatch = $cloudWatch; 60 | $this->failedJobs = $failedJobs; 61 | $this->sqs = $sqs; 62 | 63 | $this->queueResolver = function () { 64 | return config('vapor-ui.queue.name'); 65 | }; 66 | } 67 | 68 | /** 69 | * The total of pending jobs. 70 | * 71 | * @return int 72 | */ 73 | public function pending() 74 | { 75 | $prefix = config('vapor-ui.queue.prefix'); 76 | 77 | if (empty($prefix)) { 78 | return 0; 79 | } 80 | 81 | $queue = $this->queueResolver->__invoke(); 82 | 83 | return collect($this->sqs->getQueueAttributes([ 84 | 'AttributeNames' => [ 85 | 'ApproximateNumberOfMessages', 86 | 'ApproximateNumberOfMessagesNotVisible', 87 | 'ApproximateNumberOfMessagesDelayed', 88 | ], 89 | 'QueueUrl' => "$prefix/$queue", 90 | ])['Attributes'])->sum(); 91 | } 92 | 93 | /** 94 | * The timeseries of the failed jobs in past 24 hours. 95 | * 96 | * @return array 97 | */ 98 | public function failedTimeseries() 99 | { 100 | $dayAgo = now()->subHours(23)->setMinute(0); 101 | 102 | return $this->failedJobs()->map(function ($job) { 103 | return Carbon::parse($job->failed_at) 104 | ->setMinute(0) 105 | ->setSecond(0) 106 | ->timestamp; 107 | })->filter(function ($timestamp) use ($dayAgo) { 108 | return Carbon::parse($timestamp)->greaterThan($dayAgo); 109 | })->groupBy(function ($timestamp) { 110 | return $timestamp; 111 | })->union(collect(range(0, 23))->mapWithKeys(function ($hourAgo) { 112 | $hourAgo = now() 113 | ->subHours($hourAgo) 114 | ->setMinute(0) 115 | ->setSecond(0) 116 | ->timestamp; 117 | 118 | return [$hourAgo => collect()]; 119 | }))->map(function ($group) { 120 | return $group->count(); 121 | })->map(function ($count, $timestamp) { 122 | return [ 123 | 'timestamp' => $timestamp, 124 | 'value' => $count, 125 | ]; 126 | })->sortBy('timestamp')->values()->all(); 127 | } 128 | 129 | /** 130 | * The total of failed jobs in past given hours ago. 131 | * 132 | * @param int $hoursAgo 133 | * @return int 134 | */ 135 | public function failedSumByHoursAgo($hoursAgo) 136 | { 137 | $hoursAgo = now()->subHours($hoursAgo); 138 | 139 | return $this->failedJobs()->filter(function ($job) use ($hoursAgo) { 140 | return Carbon::parse($job->failed_at)->greaterThan($hoursAgo); 141 | })->count(); 142 | } 143 | 144 | /** 145 | * Gets the average of failed jobs from the given period. 146 | * 147 | * @param int $fromHoursAgo 148 | * @param int $untilHoursAgo 149 | * @return int 150 | */ 151 | public function failedAverageByHoursAgo($fromHoursAgo, $untilHoursAgo) 152 | { 153 | $from = now()->subHours($fromHoursAgo); 154 | $until = now()->subHours($untilHoursAgo); 155 | 156 | return (int) ($this->failedJobs()->filter(function ($job) use ($from, $until) { 157 | $failedAt = Carbon::parse($job->failed_at); 158 | 159 | return $failedAt->greaterThanOrEqualTo($from) && $failedAt->lessThan($until); 160 | })->count() / ($fromHoursAgo - $untilHoursAgo)); 161 | } 162 | 163 | /** 164 | * The timeseries of the processed jobs in past 24 hours. 165 | * 166 | * @return array 167 | */ 168 | public function processedTimeseries() 169 | { 170 | return collect($this->logs([ 171 | 'StartTime' => now()->subHours(23)->setMinute(0)->format('Y-m-d H:i:s'), 172 | 'Period' => 60 * 60, 173 | 'EndTime' => now()->addDays(1)->format('Y-m-d H:i:s'), 174 | 'MetricName' => 'NumberOfMessagesReceived', 175 | 'Statistics' => ['Sum'], 176 | ])['Datapoints'])->map(function ($result, $key) { 177 | return [ 178 | 'timestamp' => $result['Timestamp']->getTimestamp(), 179 | 'value' => $result['Sum'], 180 | ]; 181 | })->sortBy('timestamp')->values()->all(); 182 | } 183 | 184 | /** 185 | * The total of processed jobs in past given hours ago. 186 | * 187 | * @param int $hoursAgo 188 | * @return int 189 | */ 190 | public function processedSumByHoursAgo($hoursAgo) 191 | { 192 | return (int) collect($this->logs([ 193 | 'StartTime' => now()->subHours($hoursAgo)->format('Y-m-d H:i:s'), 194 | 'Period' => 60 * 60 * $hoursAgo, 195 | 'EndTime' => now()->addDays(1)->format('Y-m-d H:i:s'), 196 | 'MetricName' => 'NumberOfMessagesReceived', 197 | 'Statistics' => ['Sum'], 198 | ])['Datapoints'])->pluck('Sum')->sum(); 199 | } 200 | 201 | /** 202 | * Gets the average of processed jobs from the given period. 203 | * 204 | * @param int $fromHoursAgo 205 | * @param int $untilHoursAgo 206 | * @return int 207 | */ 208 | public function processedAverageByHoursAgo($fromHoursAgo, $untilHoursAgo) 209 | { 210 | return (int) (collect($this->logs([ 211 | 'StartTime' => now()->subHours($fromHoursAgo)->format('Y-m-d H:i:s'), 212 | 'EndTime' => now()->subHours($untilHoursAgo)->format('Y-m-d H:i:s'), 213 | 'Period' => 60 * 60 * $fromHoursAgo, 214 | 'MetricName' => 'NumberOfMessagesReceived', 215 | 'Statistics' => ['Sum'], 216 | ])['Datapoints'])->pluck('Sum')->sum() / ($fromHoursAgo - $untilHoursAgo)); 217 | } 218 | 219 | /** 220 | * Sets the queue resolver. 221 | * 222 | * @param callable $callback 223 | * @return void 224 | */ 225 | public function resolveQueueUsing($callback) 226 | { 227 | $this->queueResolver = $callback; 228 | } 229 | 230 | /** 231 | * Gets the failed jobs. 232 | * 233 | * It memoizes the the `all` call to failed jobs provider. 234 | * 235 | * @return \Illuminate\Support\Collection 236 | */ 237 | protected function failedJobs() 238 | { 239 | if ($this->failedJobsCollection === null) { 240 | try { 241 | $this->failedJobsCollection = collect($this->failedJobs->all()) 242 | ->filter(function ($content) { 243 | $queue = $this->queueResolver->__invoke(); 244 | $prefix = config('vapor-ui.queue.prefix'); 245 | 246 | return "$prefix/$queue" == ((array) $content)['queue']; 247 | }); 248 | } catch (QueryException $e) { 249 | $this->failedJobsCollection = collect(); 250 | } 251 | } 252 | 253 | return $this->failedJobsCollection; 254 | } 255 | 256 | /** 257 | * Performs a request to the Cloud Watch Logs API. 258 | * 259 | * @param array $payload 260 | * @return array 261 | */ 262 | protected function logs($payload) 263 | { 264 | return $this->cloudWatch->getMetricStatistics(array_merge_recursive([ 265 | 'Dimensions' => [[ 266 | 'Name' => 'QueueName', 'Value' => $this->queueResolver->__invoke(), 267 | ]], 268 | 'Namespace' => 'AWS/SQS', 269 | ], $payload))->toArray(); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/Repositories/JobsRepository.php: -------------------------------------------------------------------------------- 1 | provider = $provider; 29 | } 30 | 31 | /** 32 | * Gets the job by the given id. 33 | * 34 | * @param string $group 35 | * @param string $id 36 | * @param array $filters 37 | * @return Job|null 38 | */ 39 | public function get($group, $id, $filters = []) 40 | { 41 | if ($content = $this->provider->find($id)) { 42 | return new Job((array) $content, $group, $filters); 43 | } 44 | } 45 | 46 | /** 47 | * Search for the jobs by the given filters. 48 | * 49 | * @param string $group 50 | * @param array $filters 51 | * @return SearchResult 52 | */ 53 | public function search($group, $filters) 54 | { 55 | $limit = 50; 56 | $offset = $this->offset($filters); 57 | $startTime = $this->startTime($filters); 58 | $queue = $this->queue($filters); 59 | $queryTerms = $this->queryTerms($filters); 60 | 61 | $all = collect($this->provider->all()); 62 | 63 | $entries = $all 64 | ->reverse() 65 | ->filter(function ($content) use ($queue) { 66 | return $queue == ((array) $content)['queue']; 67 | })->filter(function ($content) use ($queryTerms, $startTime) { 68 | foreach ($queryTerms as $term) { 69 | if (! Str::contains(json_encode($content), $term)) { 70 | return false; 71 | } 72 | } 73 | 74 | return (Carbon::parse( 75 | $content->failed_at, 76 | )->timestamp * 1000) >= $startTime; 77 | })->slice($offset, $limit) 78 | ->map(function ($content) use ($group, $filters) { 79 | return new Job((array) $content, $group, $filters); 80 | })->values(); 81 | 82 | return new SearchResult( 83 | $entries, 84 | (max($offset, 1) * $limit) < $all->count() 85 | ? (string) ($offset + $limit) 86 | : null 87 | ); 88 | } 89 | 90 | /** 91 | * Gets the start time from the given $filters. 92 | * 93 | * @param array $filters 94 | * @return int|null 95 | */ 96 | protected function startTime($filters) 97 | { 98 | return isset($filters['startTime']) ? (int) $filters['startTime'] * 1000 : null; 99 | } 100 | 101 | /** 102 | * Gets the queue from the given $filters. 103 | * 104 | * @param array $filters 105 | * @return string 106 | */ 107 | protected function queue($filters) 108 | { 109 | $queue = $filters['queue']; 110 | $prefix = config('vapor-ui.queue.prefix'); 111 | 112 | return "$prefix/$queue"; 113 | } 114 | 115 | /** 116 | * Gets the query from the given $filters. 117 | * 118 | * @param array $filters 119 | * @return array 120 | */ 121 | protected function queryTerms($filters) 122 | { 123 | return isset($filters['query']) ? explode(' ', $filters['query']) : []; 124 | } 125 | 126 | /** 127 | * Gets the offset from the given $filters. 128 | * 129 | * @param array $filters 130 | * @return int|null 131 | */ 132 | protected function offset($filters) 133 | { 134 | return isset($filters['cursor']) ? (int) $filters['cursor'] : 0; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Repositories/LogsRepository.php: -------------------------------------------------------------------------------- 1 | client = $client; 59 | } 60 | 61 | /** 62 | * Gets the log by the given id. 63 | * 64 | * @param string $group 65 | * @param string $id 66 | * @param array $filters 67 | * @return Log|null 68 | */ 69 | public function get($group, $id, $filters = []) 70 | { 71 | return $this->search($group, $filters) 72 | ->entries 73 | ->firstWhere('id', $id); 74 | } 75 | 76 | /** 77 | * Search for the logs by the given filters. 78 | * 79 | * @param string $group 80 | * @param array $filters 81 | * @return SearchResult 82 | */ 83 | public function search($group, $filters = []) 84 | { 85 | try { 86 | $response = $this->client->filterLogEvents(array_filter([ 87 | 'logGroupName' => $this->logGroupName($group), 88 | 'limit' => 50, 89 | 'interleaved' => true, 90 | 'nextToken' => $this->nextToken($filters), 91 | 'startTime' => $this->startTime($filters), 92 | 'filterPattern' => $this->filterPattern($filters), 93 | ]))->toArray(); 94 | } catch (CloudWatchLogsException $e) { 95 | $resourceNotFoundException = '"__type":"ResourceNotFoundException"'; 96 | 97 | if (! Str::contains($e->getMessage(), $resourceNotFoundException)) { 98 | throw $e; 99 | } 100 | 101 | $response = [ 102 | 'events' => [], 103 | ]; 104 | } 105 | 106 | $entries = collect($response['events']) 107 | ->filter(function ($event) use ($filters) { 108 | return empty($filters['type']) || $filters['type'] === 'timeout' || @json_decode($event['message']); 109 | })->map(function ($event) use ($group, $filters) { 110 | if (array_key_exists('message', $event) 111 | && ($message = json_decode($event['message'], true))) { 112 | $event['message'] = $message; 113 | } 114 | 115 | return new Log($event, $group, $filters); 116 | })->values(); 117 | 118 | return new SearchResult($entries, $response['nextToken'] ?? null); 119 | } 120 | 121 | /** 122 | * Returns the log group name from the given $group. 123 | * 124 | * @param string $group 125 | * @return string 126 | */ 127 | protected function logGroupName($group) 128 | { 129 | $vaporUi = config('vapor-ui'); 130 | 131 | $environment = $vaporUi['environment']; 132 | 133 | $usingDockerRuntime = Str::endsWith( 134 | $_ENV['AWS_LAMBDA_FUNCTION_NAME'] ?? '', 135 | "$environment-d" 136 | ); 137 | 138 | return sprintf( 139 | '/aws/lambda/vapor-%s-%s%s%s', 140 | $vaporUi['project'], 141 | $environment, 142 | $usingDockerRuntime ? '-d' : '', 143 | in_array($group, ['cli', 'queue']) ? "-$group" : '' 144 | ); 145 | } 146 | 147 | /** 148 | * Gets the next token from the given $filters. 149 | * 150 | * @param array $filters 151 | * @return string|null 152 | */ 153 | protected function nextToken($filters) 154 | { 155 | return isset($filters['cursor']) ? (string) $filters['cursor'] : null; 156 | } 157 | 158 | /** 159 | * Gets the start time from the given $filters. 160 | * 161 | * @param array $filters 162 | * @return int|null 163 | */ 164 | protected function startTime($filters) 165 | { 166 | return isset($filters['startTime']) ? (int) $filters['startTime'] * 1000 : null; 167 | } 168 | 169 | /** 170 | * Gets the filter pattern from the given $filters. 171 | * 172 | * @param array $filters 173 | * @return string 174 | */ 175 | protected function filterPattern($filters) 176 | { 177 | $include = ''; 178 | 179 | if (array_key_exists('type', $filters)) { 180 | if ($filters['type'] === 'timeout') { 181 | $include .= '"Task timed out after" '; 182 | } else { 183 | $include .= sprintf('"message" "level_name" "%s" ', strtoupper($filters['type'])); 184 | } 185 | } 186 | 187 | $query = $filters['query'] ?? ''; 188 | 189 | $exclude = $this->ignore 190 | ? '- "'.collect($this->ignore)->implode('" - "').'"' 191 | : ''; 192 | 193 | $filterPattern = empty($query) 194 | ? '' 195 | : collect(explode(' ', $query)) 196 | ->map(function ($term) { 197 | return '"'.str_replace('"', '', $term).'"'; 198 | })->implode(' '); 199 | 200 | return $include.$filterPattern.' '.$exclude; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Support/Cloud.php: -------------------------------------------------------------------------------- 1 | parse(file_get_contents(config('vapor-ui.manifest', base_path('vapor.yml')))); 67 | 68 | $environment = config('vapor-ui.environment'); 69 | 70 | $queues = Arr::get( 71 | $vapor, 72 | "environments.$environment.queues", 73 | [config('vapor-ui.queue.name')] 74 | ); 75 | 76 | return collect($queues)->map(function ($queue) use ($prefix) { 77 | return str_replace("$prefix/", '', $queue); 78 | })->unique()->values()->all(); 79 | } 80 | 81 | /** 82 | * Gets the ssm path from the cloud environment. 83 | * 84 | * @return string 85 | */ 86 | public static function ssmPath() 87 | { 88 | $ssmPath = $_ENV['VAPOR_SSM_PATH'] ?? ''; 89 | 90 | return $ssmPath; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ValueObjects/Job.php: -------------------------------------------------------------------------------- 1 | content = $content; 72 | $this->group = $group; 73 | $this->filters = $filters; 74 | 75 | $this->pullId() 76 | ->pullTimestamp() 77 | ->pullException() 78 | ->pullMessage() 79 | ->normalizePayload(); 80 | } 81 | 82 | /** 83 | * Pulls the id from the content. 84 | * 85 | * @return $this 86 | */ 87 | protected function pullId() 88 | { 89 | $this->id = $this->content['id']; 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * Pulls the timestamp from the content. 96 | * 97 | * @return $this 98 | */ 99 | protected function pullTimestamp() 100 | { 101 | $this->timestamp = Carbon::parse( 102 | $this->content['failed_at'] 103 | )->timestamp * 1000; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Pulls the queue exception from the content. 110 | * 111 | * @return $this 112 | */ 113 | protected function pullException() 114 | { 115 | if (Str::startsWith($this->content['exception'], ManuallyFailedException::class)) { 116 | $exception = ManuallyFailedException::class; 117 | } else { 118 | [$exception] = explode(':', $this->content['exception']); 119 | } 120 | 121 | if (! empty($exception)) { 122 | $this->exception = $exception; 123 | } 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Pulls the queue message from the content. 130 | * 131 | * @return $this 132 | */ 133 | protected function pullMessage() 134 | { 135 | if (Str::startsWith($this->content['exception'], ManuallyFailedException::class)) { 136 | $message = 'Manually failed'; 137 | } else { 138 | [$_, $message] = explode(':', $this->content['exception']); 139 | [$message] = explode(' in /', $message); 140 | [$message] = explode(' in closure', $message); 141 | } 142 | 143 | if (! empty($message)) { 144 | $this->message = trim($message); 145 | } 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Normalize the payload. 152 | * 153 | * @return $this 154 | */ 155 | protected function normalizePayload() 156 | { 157 | $this->content['payload'] = json_decode($this->content['payload'], true); 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * Get the array representation of the job. 164 | * 165 | * @return array 166 | */ 167 | #[\ReturnTypeWillChange] 168 | public function jsonSerialize() 169 | { 170 | return (array) $this; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/ValueObjects/Log.php: -------------------------------------------------------------------------------- 1 | content = $content; 78 | $this->group = $group; 79 | $this->filters = $filters; 80 | 81 | $this->pullId() 82 | ->pullTimestamp() 83 | ->pullRequestId() 84 | ->pullType() 85 | ->pullLocation(); 86 | } 87 | 88 | /** 89 | * Pulls the id from the content. 90 | * 91 | * @return $this 92 | */ 93 | protected function pullId() 94 | { 95 | $this->id = $this->content['eventId']; 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Pulls the timestamp from the content. 102 | * 103 | * @return $this 104 | */ 105 | protected function pullTimestamp() 106 | { 107 | $this->timestamp = $this->content['timestamp']; 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Pulls the request id from the content. 114 | * 115 | * @return $this 116 | */ 117 | protected function pullRequestId() 118 | { 119 | $this->requestId = Arr::get($this->content, 'message.context.aws_request_id'); 120 | 121 | Arr::forget($this->content, 'message.context.aws_request_id'); 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Pulls the location from the content. 128 | * 129 | * @return $this 130 | */ 131 | protected function pullLocation() 132 | { 133 | $location = Arr::get($this->content, 'message.context.exception.file'); 134 | 135 | if ($location) { 136 | $this->location = Str::replaceFirst( 137 | '/var/task/', 138 | '', 139 | $location 140 | ); 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Pulls the type from the content. 148 | * 149 | * @return $this 150 | */ 151 | protected function pullType() 152 | { 153 | if (is_string($this->content['message']) && preg_match('/Task timed out after/', $this->content['message']) > 0) { 154 | $this->type = 'TIMEOUT'; 155 | } else { 156 | $this->type = Arr::get($this->content, 'message.level_name', 'RAW'); 157 | } 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * Get the array representation of the log. 164 | * 165 | * @return array 166 | */ 167 | #[\ReturnTypeWillChange] 168 | public function jsonSerialize() 169 | { 170 | return (array) $this; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/ValueObjects/SearchResult.php: -------------------------------------------------------------------------------- 1 | entries = $entries; 34 | $this->cursor = $cursor; 35 | } 36 | 37 | /** 38 | * Get the array representation of the search result. 39 | * 40 | * @return array 41 | */ 42 | #[\ReturnTypeWillChange] 43 | public function jsonSerialize() 44 | { 45 | return (array) $this; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/VaporUiServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerRoutes(); 27 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'vapor-ui'); 28 | 29 | if ($this->app->runningInConsole()) { 30 | $this->publishes([ 31 | __DIR__.'/../config/vapor-ui.php' => config_path('vapor-ui.php'), 32 | ], 'vapor-ui-config'); 33 | 34 | $this->publishes([ 35 | __DIR__.'/../public' => public_path('vendor/vapor-ui'), 36 | ], ['vapor-ui-assets', 'laravel-assets']); 37 | 38 | $this->publishes([ 39 | __DIR__.'/../stubs/VaporUiServiceProvider.stub' => app_path('Providers/VaporUiServiceProvider.php'), 40 | ], 'vapor-ui-provider'); 41 | 42 | $this->commands([ 43 | Console\InstallCommand::class, 44 | Console\PublishCommand::class, 45 | ]); 46 | } 47 | } 48 | 49 | /** 50 | * Register the service provider. 51 | * 52 | * @return void 53 | */ 54 | public function register() 55 | { 56 | $this->mergeConfigFrom(__DIR__.'/../config/vapor-ui.php', 'vapor-ui'); 57 | $this->mergeConfigFrom(__DIR__.'/../config/runtime.php', 'vapor-ui'); 58 | 59 | $this->ensureVaporUiIsConfigured(); 60 | 61 | $this->registerClients(); 62 | } 63 | 64 | /** 65 | * Binds an implementation of the CloudWatch Client. 66 | * 67 | * @return void 68 | */ 69 | public function registerClients() 70 | { 71 | collect([CloudWatchClient::class, CloudWatchLogsClient::class, SqsClient::class])->each(function ($client) { 72 | $this->app->singleton($client, function () use ($client) { 73 | $config = config('vapor-ui'); 74 | 75 | $cloudWatchConfig = [ 76 | 'region' => $config['region'], 77 | 'version' => 'latest', 78 | ]; 79 | 80 | if ($config['key'] && $config['secret']) { 81 | $cloudWatchConfig['credentials'] = Arr::only( 82 | $config, ['key', 'secret', 'token'] 83 | ); 84 | } 85 | 86 | return new $client($cloudWatchConfig); 87 | }); 88 | }); 89 | } 90 | 91 | /** 92 | * Register the Vapor UI routes. 93 | * 94 | * @return void 95 | */ 96 | protected function registerRoutes() 97 | { 98 | Route::group([ 99 | 'domain' => config('vapor-ui.domain', null), 100 | 'prefix' => config('vapor-ui.path', 'vapor-ui'), 101 | ], function () { 102 | $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /stubs/VaporUiServiceProvider.stub: -------------------------------------------------------------------------------- 1 | gate(); 17 | } 18 | 19 | /** 20 | * Register the Vapor UI gate. 21 | * 22 | * This gate determines who can access Vapor UI in non-local environments. 23 | */ 24 | protected function gate(): void 25 | { 26 | Gate::define('viewVaporUI', function (User $user = null) { 27 | return in_array(optional($user)->email, [ 28 | // 29 | ]); 30 | }); 31 | } 32 | 33 | /** 34 | * Register any application services. 35 | */ 36 | public function register(): void 37 | { 38 | // 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | purge: { 5 | content: ['./resources/views/**/*.blade.php', './resources/js/**/*.vue', './resources/js/**/*.js'], 6 | 7 | options: { 8 | whitelist: [ 9 | 'text-green-900', 10 | 'text-green-800', 11 | 'bg-green-100', 12 | 'text-blue-900', 13 | 'text-blue-800', 14 | 'bg-blue-100', 15 | 'text-yellow-900', 16 | 'text-yellow-800', 17 | 'bg-yellow-100', 18 | 'text-red-900', 19 | 'text-red-800', 20 | 'bg-red-100', 21 | 'text-gray-900', 22 | 'text-gray-800', 23 | 'bg-gray-100', 24 | ], 25 | }, 26 | }, 27 | theme: { 28 | extend: { 29 | fontFamily: { 30 | sans: ['Nunito', ...defaultTheme.fontFamily.sans], 31 | }, 32 | }, 33 | }, 34 | variants: {}, 35 | plugins: [require('@tailwindcss/ui')], 36 | }; 37 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | const tailwindcss = require('tailwindcss'); 3 | const webpack = require('webpack'); 4 | 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Mix Asset Management 8 | |-------------------------------------------------------------------------- 9 | | 10 | | Mix provides a clean, fluent API for defining some Webpack build steps 11 | | for your Laravel application. By default, we are compiling the Sass 12 | | file for the application as well as bundling up all the JS files. 13 | | 14 | */ 15 | 16 | mix.options({ 17 | terser: { 18 | terserOptions: { 19 | compress: { 20 | drop_console: true, 21 | }, 22 | }, 23 | }, 24 | }) 25 | .postCss('resources/css/vapor-ui.css', 'public/app.css', [require('postcss-import'), require('tailwindcss')]) 26 | .setPublicPath('public') 27 | .js('resources/js/app.js', 'public') 28 | .version() 29 | .webpackConfig({ 30 | resolve: { 31 | symlinks: false, 32 | alias: { 33 | '@': path.resolve(__dirname, 'resources/js/'), 34 | }, 35 | }, 36 | plugins: [new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)], 37 | }) 38 | .copy('public', '../vapor-uitest/public/vendor/vapor-ui') 39 | .copy('public', './vendor/orchestra/testbench-core/laravel/public/vendor/vapor-ui'); 40 | --------------------------------------------------------------------------------