├── public └── vendor │ └── lighthouse-dashboard │ ├── css │ ├── .gitkeep │ ├── app.css │ └── app.css.map │ ├── js │ ├── .gitkeep │ ├── 2.js.LICENSE.txt │ ├── 5.js.LICENSE.txt │ ├── 1.js.LICENSE.txt │ ├── 6.js.LICENSE.txt │ ├── 4.js │ └── 7.js │ ├── images │ └── favicon.png │ └── mix-manifest.json ├── readme1.png ├── readme2.png ├── readme3.png ├── readme4.png ├── readme5.png ├── dashboard.png ├── dashboard-model.png ├── tests ├── LighthouseDashboard.php ├── Utils │ ├── Models │ │ ├── Badge.php │ │ ├── Color.php │ │ ├── Category.php │ │ └── Product.php │ ├── Traits │ │ ├── InertiaAssertions.php │ │ ├── TypeAssertions.php │ │ └── MakeCustomGraphQLRequests.php │ ├── Schemas │ │ ├── schema.graphql │ │ ├── schema-with-internal-error.graphql │ │ └── schema-full.graphql │ └── Database │ │ ├── Factories │ │ ├── ColorFactory.php │ │ ├── CategoryFactory.php │ │ └── ProductFactory.php │ │ └── Migrations │ │ ├── 2020_09_04_174411_create_test_colors_table.php │ │ ├── 2020_09_04_174411_create_test_categories_table.php │ │ └── 2020_09_04_174411_create_test_products_table.php ├── Unit │ ├── SlientTracingTest.php │ └── ManipulateResultListenerTest.php ├── InertiaTestResponse.php ├── Feature │ ├── OperationWithErrorTest.php │ ├── IgnoreClientsTest.php │ ├── OperationTracingTest.php │ ├── OperationsWithClientFilterTest.php │ ├── FieldSumaryTest.php │ ├── WelcomeStatisticsTest.php │ ├── OperationSumaryTest.php │ ├── SyncGraphQLSchemaTest.php │ ├── TypesTest.php │ ├── OperationsSlowTest.php │ ├── OperationsTopTest.php │ └── OperationsWithDateFilterTest.php └── TestCase.php ├── .gitignore ├── resources ├── js │ ├── plugins │ │ ├── numeral.js │ │ ├── apex-charts.js │ │ ├── code-highlight.js │ │ ├── milliseconds.js │ │ ├── vuetify.js │ │ └── inertia.js │ ├── app.js │ ├── components │ │ ├── tracing │ │ │ └── TracingExecution.vue │ │ ├── Field.vue │ │ ├── charts │ │ │ ├── ClientsChart.vue │ │ │ └── RequestsChart.vue │ │ ├── Filters.vue │ │ ├── OperationSumary.vue │ │ └── FieldSumary.vue │ ├── layouts │ │ └── default.vue │ └── pages │ │ ├── Operation.vue │ │ ├── Welcome.vue │ │ ├── Errors.vue │ │ ├── Types.vue │ │ └── Operations.vue ├── views │ └── app.blade.php └── css │ └── app.scss ├── .docker └── docker-compose.yml ├── database ├── factories │ ├── SchemaFactory.php │ ├── ClientFactory.php │ ├── OperationFactory.php │ ├── FieldFactory.php │ ├── TypeFactory.php │ ├── RequestFactory.php │ └── TracingFactory.php ├── migrations │ ├── 2020_09_06_195922_create_operations_table.php │ ├── 2020_09_04_174411_create_clients_table.php │ ├── 2020_09_07_210803_create_errors_table.php │ ├── 2020_09_05_174411_create_schemas_table.php │ ├── 2020_09_06_181639_create_types_table.php │ ├── 2020_09_07_210803_create_tracings_table.php │ ├── 2020_09_06_182528_create_fields_table.php │ └── 2020_09_06_222554_create_requests_table.php └── seeders │ └── LighthouseDashboardSeeder.php ├── src ├── Http │ └── Controllers │ │ ├── FieldController.php │ │ ├── ErrorsController.php │ │ ├── WelcomeController.php │ │ ├── TypeController.php │ │ └── OperationController.php ├── Traits │ ├── DisableDashboardMetrics.php │ └── ParsesRangeFilter.php ├── Models │ ├── Tracing.php │ ├── Schema.php │ ├── Type.php │ ├── Error.php │ ├── Client.php │ ├── Field.php │ ├── Request.php │ └── Operation.php ├── Console │ └── Commands │ │ ├── PublishCommand.php │ │ ├── PublishAssetsCommand.php │ │ ├── MigrateCommand.php │ │ └── SeedCommand.php ├── routes.php ├── Listeners │ └── ManipulateResultListener.php ├── Providers │ └── LighthouseDashboardServiceProvider.php └── Actions │ ├── SyncGraphQLSchema.php │ └── StoreMetrics.php ├── .devcontainer └── devcontainer.json ├── webpack.mix.js ├── config └── lighthouse-dashboard.php ├── phpunit.xml ├── package.json ├── composer.json ├── .github └── workflows │ └── ci.yaml └── README.md /public/vendor/lighthouse-dashboard/css/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /readme1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/readme1.png -------------------------------------------------------------------------------- /readme2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/readme2.png -------------------------------------------------------------------------------- /readme3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/readme3.png -------------------------------------------------------------------------------- /readme4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/readme4.png -------------------------------------------------------------------------------- /readme5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/readme5.png -------------------------------------------------------------------------------- /dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/dashboard.png -------------------------------------------------------------------------------- /dashboard-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robsontenorio/lighthouse-dashboard/HEAD/dashboard-model.png -------------------------------------------------------------------------------- /tests/LighthouseDashboard.php: -------------------------------------------------------------------------------- 1 | (https://vitorluizc.github.io/) 4 | * Released under the MIT License. 5 | */ 6 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/5.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * @bitty/pipe v0.0.4 3 | * (c) Vitor Luiz Cavalcanti (https://vitorluizc.github.io/) 4 | * Released under the MIT License. 5 | */ 6 | -------------------------------------------------------------------------------- /resources/js/plugins/code-highlight.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueCodeHighlight from 'vue-code-highlight'; 3 | import 'vue-code-highlight/themes/prism-tomorrow.css' 4 | import 'prism-es6/components/prism-graphql'; 5 | 6 | Vue.use(VueCodeHighlight) -------------------------------------------------------------------------------- /resources/js/plugins/milliseconds.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.filter('milliseconds', function (value) { 4 | let ms = Math.floor(value / 1000000); 5 | 6 | if (ms === 0) { 7 | return "< 1ms"; 8 | } 9 | 10 | return ms + "ms"; 11 | }) -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/app.js": "/js/app.js?id=26ab6c84ba9f7a0fca95", 3 | "/css/app.css": "/css/app.css?id=1289e70da44103162aee", 4 | "/js/app.js.map": "/js/app.js.map?id=b723f2cd1d1d12a13509", 5 | "/css/app.css.map": "/css/app.css.map?id=b01bb5aeee6b30f93d44" 6 | } 7 | -------------------------------------------------------------------------------- /tests/Utils/Traits/InertiaAssertions.php: -------------------------------------------------------------------------------- 1 | $this->faker->name 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Utils/Models/Color.php: -------------------------------------------------------------------------------- 1 | hasMany(Product::class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /database/factories/ClientFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->userName, 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Utils/Models/Category.php: -------------------------------------------------------------------------------- 1 | hasMany(Product::class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Utils/Schemas/schema.graphql: -------------------------------------------------------------------------------- 1 | "A beautiful color" 2 | type Color { 3 | id: ID! 4 | name: String! 5 | } 6 | 7 | "Our secret product" 8 | type Product { 9 | id: ID! 10 | "The name of product" 11 | name: String! 12 | color: Color @belongsTo 13 | } 14 | 15 | type Query { 16 | "List all products" 17 | products: [Product] @all 18 | } -------------------------------------------------------------------------------- /database/factories/OperationFactory.php: -------------------------------------------------------------------------------- 1 | Field::factory() 15 | ]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/1.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Lodash 4 | * Copyright OpenJS Foundation and other contributors 5 | * Released under MIT license 6 | * Based on Underscore.js 1.8.3 7 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 8 | */ 9 | -------------------------------------------------------------------------------- /tests/Utils/Database/Factories/ColorFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Utils/Database/Factories/CategoryFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Http/Controllers/FieldController.php: -------------------------------------------------------------------------------- 1 | parseRange($request); 16 | 17 | return $field->sumaryWithClients($range); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /database/factories/FieldFactory.php: -------------------------------------------------------------------------------- 1 | Type::factory(), 14 | 'name' => $this->faker->word . $this->faker->randomNumber(3), 15 | 'description' => $this->faker->sentence, 16 | 'type_def' => $this->faker->name 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Utils/Schemas/schema-with-internal-error.graphql: -------------------------------------------------------------------------------- 1 | type Badge { 2 | id: ID! 3 | name: String! 4 | } 5 | 6 | type Person { 7 | id: ID! 8 | name: String! 9 | } 10 | 11 | type Product { 12 | id: ID! 13 | name: String! 14 | person: Person! @belongsTo # The internal implementation will throw a error. See model "Product@person" 15 | badges: [Badge] @hasMany # The internal implementation will throw a error. See model "Product@badges" 16 | } 17 | 18 | type Query { 19 | products: [Product] @all 20 | } -------------------------------------------------------------------------------- /src/Traits/DisableDashboardMetrics.php: -------------------------------------------------------------------------------- 1 | swap(ManipulateResultListener::class, new class 13 | { 14 | public function handle(ManipulateResult $result) 15 | { 16 | unset($result->result->extensions['tracing']); 17 | } 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lighthouse-dashboard", 3 | "dockerComposeFile": "../.docker/docker-compose.yml", 4 | "service": "dashboard-app", 5 | "workspaceFolder": "/var/www/app", 6 | "settings": { 7 | "terminal.integrated.shell.linux": "/bin/bash" 8 | }, 9 | // Add the IDs of extensions you want installed when the container is created in the array below. 10 | "extensions": [ 11 | "bmewburn.vscode-intelephense-client", 12 | "octref.vetur", 13 | "prisma.vscode-graphql" 14 | ], 15 | // Comment out if you want to use root 16 | "remoteUser": "appuser" 17 | } -------------------------------------------------------------------------------- /tests/Utils/Database/Factories/ProductFactory.php: -------------------------------------------------------------------------------- 1 | ColorFactory::new(), 17 | 'category_id' => CategoryFactory::new(), 18 | 'name' => $this->faker->name 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Utils/Schemas/schema-full.graphql: -------------------------------------------------------------------------------- 1 | "A beautiful color" 2 | type Color { 3 | id: ID! 4 | name: String! 5 | } 6 | 7 | "A category" 8 | type Category{ 9 | id: ID! 10 | name: String! 11 | } 12 | 13 | "Our new secret product" 14 | type Product { 15 | id: ID! 16 | "The greate name of product" 17 | name: String! 18 | color: Color! @belongsTo 19 | category: Category! @belongsTo 20 | } 21 | 22 | type Query { 23 | "List all products" 24 | products: [Product] @all 25 | "List all categories" 26 | categories: [Category] @all 27 | } -------------------------------------------------------------------------------- /resources/js/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify' 3 | import 'vuetify/dist/vuetify.min.css' 4 | 5 | Vue.use(Vuetify) 6 | 7 | const opts = { 8 | themes: { 9 | light: { 10 | primary: '#58656f', 11 | secondary: '#424242', 12 | default: '#f5f5f5', 13 | // background: '#E8EAF6', 14 | background: '#f4f5f7', 15 | info: '#2196F3', 16 | warning: '#FB8C00', 17 | error: '#FF5252', 18 | success: '#4CAF50' 19 | } 20 | } 21 | } 22 | 23 | export default new Vuetify(opts) -------------------------------------------------------------------------------- /database/factories/TypeFactory.php: -------------------------------------------------------------------------------- 1 | Schema::factory(), 14 | 'name' => $this->faker->unique()->firstName, 15 | 'description' => $this->faker->sentence 16 | ]; 17 | } 18 | 19 | public function ofQueryType() 20 | { 21 | return $this->state([ 22 | 'name' => 'Query' 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Utils/Database/Migrations/2020_09_04_174411_create_test_colors_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->timestamps(); 15 | }); 16 | } 17 | 18 | public function down() 19 | { 20 | Schema::dropIfExists('colors'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Utils/Database/Migrations/2020_09_04_174411_create_test_categories_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->timestamps(); 15 | }); 16 | } 17 | 18 | public function down() 19 | { 20 | Schema::dropIfExists('categories'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lighthouse Dashboard 7 | 8 | 9 | 10 | 11 | 12 | 13 | @inertia 14 | 15 | -------------------------------------------------------------------------------- /resources/js/plugins/inertia.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { InertiaApp } from '@inertiajs/inertia-vue' 3 | import DefaultLayout from '../layouts/default' 4 | 5 | Vue.use(InertiaApp) 6 | 7 | const render = h => h(InertiaApp, { 8 | props: { 9 | initialPage: JSON.parse(app.dataset.page), 10 | resolveComponent: (name) => { 11 | return import(`@/pages/${name}`).then(module => { 12 | if (!module.default.layout) { 13 | module.default.layout = DefaultLayout 14 | } 15 | return module.default 16 | }) 17 | }, 18 | }, 19 | }) 20 | 21 | export default render -------------------------------------------------------------------------------- /database/factories/RequestFactory.php: -------------------------------------------------------------------------------- 1 | Field::factory(), 16 | 'operation_id' => Operation::factory(), 17 | 'client_id' => Client::factory(), 18 | 'requested_at' => $this->faker->dateTimeBetween('-1 month', 'now'), 19 | 'duration' => $this->faker->randomNumber(8) 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/Tracing.php: -------------------------------------------------------------------------------- 1 | 'array', 17 | ]; 18 | 19 | public function getConnectionName() 20 | { 21 | return config('lighthouse-dashboard.connection'); 22 | } 23 | 24 | public function request(): BelongsTo 25 | { 26 | return $this->belongsTo(Request::class); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | 3 | mix.setPublicPath('public/vendor/lighthouse-dashboard') 4 | .js('resources/js/app.js', 'js/') 5 | .sass('resources/css/app.scss', 'css/') 6 | .browserSync('localhost:8080') // change this while developing in different host/port 7 | .webpackConfig({ 8 | output: { 9 | publicPath: '/vendor/lighthouse-dashboard/', 10 | chunkFilename: 'js/[name].js?id=[chunkhash]' 11 | }, 12 | resolve: { 13 | alias: { 14 | 'vue$': 'vue/dist/vue.runtime.esm.js', 15 | '@': path.resolve('resources/js'), 16 | }, 17 | }, 18 | }) 19 | .sourceMaps() 20 | .version() 21 | -------------------------------------------------------------------------------- /src/Console/Commands/PublishCommand.php: -------------------------------------------------------------------------------- 1 | callSilent('vendor:publish', [ 21 | '--tag' => 'lighthouse-dashboard', 22 | '--force' => true 23 | ]); 24 | 25 | $this->info("Published assets and config file for Lighthouse Dashboard."); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Console/Commands/PublishAssetsCommand.php: -------------------------------------------------------------------------------- 1 | callSilent('vendor:publish', [ 21 | '--tag' => 'lighthouse-dashboard-assets', 22 | '--force' => true 23 | ]); 24 | 25 | $this->info("Published fresh assets for Lighthouse Dashboard."); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Models/Schema.php: -------------------------------------------------------------------------------- 1 | hasMany(Operation::class); 24 | } 25 | 26 | public function types(): HasMany 27 | { 28 | return $this->hasMany(Type::class); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/routes.php: -------------------------------------------------------------------------------- 1 | 'lighthouse-dashboard', 'middleware' => SubstituteBindings::class, 'namespace' => 'App\Http\Controllers'], function () { 7 | 8 | Route::get('/', 'WelcomeController@index'); 9 | 10 | Route::get('/operations', 'OperationController@index'); 11 | Route::get('/operations/{operation}', 'OperationController@show'); 12 | Route::get('/operations/{operation}/sumary', 'OperationController@sumary'); 13 | 14 | Route::get('/types', 'TypeController@index'); 15 | Route::get('/fields/{field}/sumary', 'FieldController@sumary'); 16 | 17 | Route::get('/errors', 'ErrorsController@index'); 18 | }); 19 | -------------------------------------------------------------------------------- /database/factories/TracingFactory.php: -------------------------------------------------------------------------------- 1 | Request::factory(), 14 | 'start_time' => $this->faker->dateTimeBetween('last month'), 15 | 'end_time' => $this->faker->dateTimeBetween('last month'), 16 | 'duration' => $this->faker->randomNumber(), 17 | 'request' => $this->faker->sentence(), 18 | 'execution' => $this->faker->sentence(), 19 | 'created_at' => $this->faker->dateTimeBetween('last month') 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Utils/Database/Migrations/2020_09_04_174411_create_test_products_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->foreignId('color_id')->constrained(); 15 | $table->foreignId('category_id')->constrained(); 16 | $table->timestamps(); 17 | }); 18 | } 19 | 20 | public function down() 21 | { 22 | Schema::dropIfExists('products'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Models/Type.php: -------------------------------------------------------------------------------- 1 | belongsTo(Schema::class); 25 | } 26 | 27 | public function fields(): HasMany 28 | { 29 | return $this->hasMany(Field::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Console/Commands/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | call('migrate', [ 21 | '--force' => true, 22 | '--path' => 'vendor/robsontenorio/lighthouse-dashboard/database/migrations', 23 | '--database' => config('lighthouse-dashboard.connection'), 24 | ]); 25 | $this->info("Finished Lighthouse Dashboard migrations."); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/js/components/tracing/TracingExecution.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /src/Console/Commands/SeedCommand.php: -------------------------------------------------------------------------------- 1 | info("Refreshing database ..."); 21 | 22 | $this->call('migrate:refresh', [ 23 | '--seeder' => 'LighthouseDashboardSeeder', 24 | '--path' => 'vendor/robsontenorio/lighthouse-dashboard/database/migrations', 25 | '--database' => config('lighthouse-dashboard.connection'), 26 | ]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /database/migrations/2020_09_06_195922_create_operations_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->unsignedBigInteger('field_id'); 14 | $table->timestamps(); 15 | 16 | $table->foreign('field_id')->references('id')->on('ld_fields'); 17 | }); 18 | } 19 | 20 | public function down() 21 | { 22 | Schema::dropIfExists('ld_operations'); 23 | } 24 | 25 | public function getConnection() 26 | { 27 | return config('lighthouse-dashboard.connection'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Utils/Models/Product.php: -------------------------------------------------------------------------------- 1 | belongsTo(Category::class); 14 | } 15 | 16 | public function color(): BelongsTo 17 | { 18 | return $this->belongsTo(Color::class); 19 | } 20 | 21 | public function person(): BelongsTo 22 | { 23 | // Force to test internal error. "Person" model does not exists. 24 | return $this->belongsTo(Person::class); 25 | } 26 | 27 | public function badges(): HasMany 28 | { 29 | // Force to test internal error. It should be "hasMany" relationship. 30 | return 'Ops!'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/6.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! ***************************************************************************** 2 | Copyright (c) Microsoft Corporation. 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | ***************************************************************************** */ 15 | -------------------------------------------------------------------------------- /database/migrations/2020_09_04_174411_create_clients_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->string("username"); 15 | $table->timestamps(); 16 | 17 | $table->index(['username']); 18 | }); 19 | 20 | Client::create([ 21 | 'username' => 'anonymous' 22 | ]); 23 | } 24 | 25 | public function down() 26 | { 27 | Schema::dropIfExists('ld_clients'); 28 | } 29 | 30 | public function getConnection() 31 | { 32 | return config('lighthouse-dashboard.connection'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/Controllers/ErrorsController.php: -------------------------------------------------------------------------------- 1 | get(); 17 | 18 | $range = $this->parseRange($request); 19 | $selectedClients = $request->input('clients', $clients->pluck('id')->toArray()); 20 | 21 | $errors = Error::latestIn($range, $selectedClients); 22 | 23 | return inertia('Errors', [ 24 | 'errors' => $errors, 25 | 'clients' => $clients, 26 | 'selectedClients' => $selectedClients, 27 | 'start_date' => $request->input('start_date', 'last month'), 28 | 'range' => $request->input('range') 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/migrations/2020_09_07_210803_create_errors_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('request_id')->constrained('ld_requests'); 14 | $table->string('category'); 15 | $table->text('message'); 16 | $table->text('original_exception')->nullable(); 17 | $table->text('body'); 18 | 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | public function down() 24 | { 25 | Schema::dropIfExists('ld_errors'); 26 | } 27 | 28 | public function getConnection() 29 | { 30 | return config('lighthouse-dashboard.connection'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2020_09_05_174411_create_schemas_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->string("name"); 15 | $table->string("hash")->nullable(); 16 | $table->text("schema")->nullable(); 17 | $table->timestamps(); 18 | }); 19 | 20 | AppSchema::create([ 21 | 'name' => config('app.name') 22 | ]); 23 | } 24 | 25 | public function down() 26 | { 27 | Schema::dropIfExists('ld_schemas'); 28 | } 29 | 30 | public function getConnection() 31 | { 32 | return config('lighthouse-dashboard.connection'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Utils/Traits/TypeAssertions.php: -------------------------------------------------------------------------------- 1 | 1]; 18 | 19 | $this->type = Type::where($condition)->first(); 20 | 21 | $this->assertNotNull($this->type); 22 | 23 | return $this; 24 | } 25 | 26 | /** 27 | * Assert fields of Type match to values. 28 | * 29 | * @param string[] $match Array of properties for each field to be compared. 30 | */ 31 | protected function withFields(array $match) 32 | { 33 | $typeFieldsValues = $this->type->fields->map(fn ($field) => $field->only(['name', 'description', 'type_def']))->toArray(); 34 | 35 | $this->assertEqualsCanonicalizing($typeFieldsValues, $match); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Unit/SlientTracingTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema.graphql'); 15 | } 16 | 17 | public function test_silent_tracing_configuration() 18 | { 19 | $this->graphQL(' 20 | { 21 | products{ 22 | id 23 | name 24 | } 25 | } 26 | ')->assertJsonPath('extensions.tracing', null); 27 | 28 | config(['lighthouse-dashboard.silent_tracing' => false]); 29 | 30 | $this->graphQL(' 31 | { 32 | products{ 33 | id 34 | name 35 | } 36 | } 37 | ')->assertJsonStructure(['data' => [], 'extensions' => ['tracing']]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2020_09_06_181639_create_types_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->unsignedBigInteger('schema_id'); 14 | $table->string('name'); 15 | $table->string('description')->nullable(); 16 | $table->timestamps(); 17 | 18 | $table->foreign('schema_id')->references('id')->on('ld_schemas'); 19 | $table->index(['name']); 20 | $table->index(['schema_id', 'name']); 21 | }); 22 | } 23 | 24 | public function down() 25 | { 26 | Schema::dropIfExists('ld_types'); 27 | } 28 | 29 | public function getConnection() 30 | { 31 | return config('lighthouse-dashboard.connection'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/js/components/Field.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 36 | -------------------------------------------------------------------------------- /database/migrations/2020_09_07_210803_create_tracings_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->unsignedBigInteger('request_id'); 14 | $table->text('payload'); 15 | $table->dateTime('start_time'); 16 | $table->dateTime('end_time'); 17 | $table->unsignedBigInteger('duration'); 18 | $table->json('execution'); 19 | $table->timestamps(); 20 | 21 | $table->foreign('request_id')->references('id')->on('ld_requests'); 22 | }); 23 | } 24 | 25 | public function down() 26 | { 27 | Schema::dropIfExists('ld_tracings'); 28 | } 29 | 30 | public function getConnection() 31 | { 32 | return config('lighthouse-dashboard.connection'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Models/Error.php: -------------------------------------------------------------------------------- 1 | belongsTo(Request::class); 24 | } 25 | 26 | public static function latestIn(array $range, array $selectedClients) 27 | { 28 | return Error::query() 29 | ->with(['request.client', 'request.operation.field']) 30 | ->whereHas('request', function ($query) use ($range, $selectedClients) { 31 | $query->inRange($range)->forClients($selectedClients); 32 | }) 33 | ->latest() 34 | ->take(100) 35 | ->get(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/migrations/2020_09_06_182528_create_fields_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->unsignedBigInteger('type_id'); 14 | $table->string('name'); 15 | $table->string('description')->nullable(); 16 | $table->string('type_def')->nullable(); 17 | $table->text('args')->nullable(); 18 | $table->timestamps(); 19 | 20 | $table->foreign('type_id')->references('id')->on('ld_types'); 21 | $table->index(['type_id', 'name']); 22 | $table->index(['type_id', 'name', 'type_def']); 23 | }); 24 | } 25 | 26 | public function down() 27 | { 28 | Schema::dropIfExists('ld_fields'); 29 | } 30 | 31 | public function getConnection() 32 | { 33 | return config('lighthouse-dashboard.connection'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Models/Client.php: -------------------------------------------------------------------------------- 1 | hasMany(Request::class); 24 | } 25 | 26 | public static function seriesIn(array $range, array $clients) 27 | { 28 | return Client::query() 29 | ->whereIn('id', $clients) 30 | ->withCount(['requests as total_requests' => function ($query) use ($range) { 31 | $query->isOperation()->inRange($range); 32 | }]) 33 | ->orderByDesc('total_requests') 34 | ->get() 35 | ->map(function ($item) { 36 | return ['x' => $item->username, 'y' => (int) $item['total_requests']]; 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /config/lighthouse-dashboard.php: -------------------------------------------------------------------------------- 1 | username` 9 | */ 10 | 11 | 'client_identifier' => 'username', 12 | 13 | /** 14 | * Database connection name for the dashboard. 15 | * 16 | * By default it uses different connection. You must create it. 17 | * Or set it to `null` if want to use same connection from target app. 18 | */ 19 | 20 | 'connection' => 'dashboard', 21 | 22 | /** 23 | * Silent tracing. 24 | * 25 | * This package auto-register TracingServiceProvider from "nuwave/lighthouse". 26 | * This is a required feature to make this package working. 27 | * 28 | * If you want including tracing output on server response just set it to `false`. 29 | * 30 | */ 31 | 32 | 'silent_tracing' => true, 33 | 34 | /** 35 | * Ignore clients. 36 | * 37 | * Ignore all request from these clients based on `client_identifier`. 38 | * 39 | */ 40 | 41 | 'ignore_clients' => [] 42 | ]; 43 | -------------------------------------------------------------------------------- /src/Http/Controllers/WelcomeController.php: -------------------------------------------------------------------------------- 1 | get(); 19 | 20 | $range = $this->parseRange($request); 21 | $selectedClients = $request->input('clients', $clients->pluck('id')->toArray()); 22 | 23 | $requests_series = Request::seriesIn($range, $selectedClients); 24 | $client_series = Client::seriesIn($range, $selectedClients); 25 | 26 | return inertia('Welcome', [ 27 | 'schema' => $schema, 28 | 'clients' => $clients, 29 | 'requests_series' => $requests_series, 30 | 'client_series' => $client_series, 31 | 'start_date' => $request->input('start_date', 'last month'), 32 | 'range' => $request->input('range'), 33 | 'selectedClients' => $selectedClients 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | src/ 18 | 19 | 20 | 21 | 22 | ./tests/Unit 23 | 24 | 25 | ./tests/Feature 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Traits/ParsesRangeFilter.php: -------------------------------------------------------------------------------- 1 | input('start_date', $start_date); 13 | $end_date = 'today'; 14 | $range = $request->input('range'); 15 | 16 | if ($range && $start_date === 'in custom range') { 17 | $start_date = $range[0]; 18 | $end_date = $range[1]; 19 | } 20 | 21 | if (!$range && $start_date === 'in custom range') { 22 | $start_date = 'last month'; 23 | } 24 | 25 | $start_date = Carbon::parse($start_date); 26 | $end_date = Carbon::parse($end_date); 27 | 28 | // Fix date order 29 | if ($start_date > $end_date) { 30 | $start_date_temp = $start_date; 31 | $start_date = $end_date; 32 | $end_date = $start_date_temp; 33 | } 34 | 35 | // Assure we get entire day range 36 | $start_date = $start_date->startOfDay(); 37 | $end_date = $end_date->endOfDay(); 38 | 39 | return [ 40 | 'start_date' => $start_date, 41 | 'end_date' => $end_date 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/css/app.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Source+Sans+Pro);@import url(https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css);.v-application,.v-application .title{font-family:Source Sans Pro!important}.row-pointer tbody tr:hover{cursor:pointer}.bordered{border-bottom:1px solid #efefef}.v-application code{color:#328c8c!important}.v-application code,pre[class*=language-]{background:none!important}#nprogress{pointer-events:none}#nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;transform:rotate(3deg) translateY(-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border-color:#29d transparent transparent #29d;border-style:solid;border-width:2px;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .bar,.nprogress-custom-parent #nprogress .spinner{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(1turn)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}} 2 | /*# sourceMappingURL=app.css.map*/ -------------------------------------------------------------------------------- /src/Http/Controllers/TypeController.php: -------------------------------------------------------------------------------- 1 | get(); 19 | 20 | $range = $this->parseRange($request); 21 | $selectedClients = $request->input('clients', $clients->pluck('id')->toArray()); 22 | 23 | $fields = Field::query() 24 | ->withCount(['requests as total_requests' => function (Builder $query) use ($range, $selectedClients) { 25 | return $query->forClients($selectedClients)->inRange($range); 26 | }]) 27 | ->get(); 28 | 29 | $types = Type::all() 30 | ->map(function ($type) use ($fields) { 31 | $type->fields = $fields->where('type_id', $type->id)->values(); 32 | return $type; 33 | }); 34 | 35 | return inertia('Types')->with([ 36 | 'types' => $types, 37 | 'start_date' => $request->input('start_date', 'last month'), 38 | 'range' => $request->input('range'), 39 | 'clients' => $clients, 40 | 'selectedClients' => $selectedClients 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /resources/js/components/charts/ClientsChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 64 | -------------------------------------------------------------------------------- /database/migrations/2020_09_06_222554_create_requests_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->unsignedBigInteger('field_id'); 14 | $table->unsignedBigInteger('operation_id'); 15 | $table->unsignedBigInteger('client_id'); 16 | $table->dateTime('requested_at'); 17 | $table->unsignedBigInteger('duration')->nullable(); 18 | 19 | $table->foreign('field_id')->references('id')->on('ld_fields'); 20 | $table->foreign('operation_id')->references('id')->on('ld_operations'); 21 | $table->foreign('client_id')->references('id')->on('ld_clients'); 22 | 23 | $table->index(['field_id', 'client_id', 'requested_at']); 24 | $table->index(['duration', 'requested_at']); 25 | $table->index(['client_id', 'operation_id', 'duration', 'requested_at']); 26 | $table->index(['operation_id', 'requested_at']); 27 | }); 28 | } 29 | 30 | public function down() 31 | { 32 | Schema::dropIfExists('ld_requests'); 33 | } 34 | 35 | public function getConnection() 36 | { 37 | return config('lighthouse-dashboard.connection'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /resources/js/components/charts/RequestsChart.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /tests/InertiaTestResponse.php: -------------------------------------------------------------------------------- 1 | props(), $prop)); 17 | 18 | return $this; 19 | } 20 | 21 | public function assertPropValue($prop, $value) 22 | { 23 | $this->assertHasProp($prop); 24 | 25 | if (is_callable($value)) { 26 | $value($this->props($prop)); 27 | } else { 28 | PHPUnit::assertEquals($this->props($prop), $value); 29 | } 30 | 31 | return $this; 32 | } 33 | 34 | public function assertPropCount($prop, $count) 35 | { 36 | $this->assertHasProp($prop); 37 | 38 | PHPUnit::assertCount($count, $this->props($prop)); 39 | 40 | return $this; 41 | } 42 | 43 | public function assertComponent($component) 44 | { 45 | PHPUnit::assertEquals($component, $this->original['page']['component']); 46 | 47 | return $this; 48 | } 49 | 50 | protected function props($key = null) 51 | { 52 | $props = json_decode(json_encode($this->original['page']['props']), JSON_OBJECT_AS_ARRAY); 53 | 54 | if ($key) { 55 | return Arr::get($props, $key); 56 | } 57 | 58 | return $props; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lighthouse-dashboard", 3 | "version": "0.0.1", 4 | "description": "Dashboard for Laravel Lighthouse GraphQL Server.", 5 | "author": "Robson Tenorio", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "dev": "yarn install && yarn development --watch", 10 | "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", 11 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 12 | "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" 13 | }, 14 | "dependencies": { 15 | "@inertiajs/inertia": "^0.2.3", 16 | "@inertiajs/inertia-vue": "^0.2.2", 17 | "apexcharts": "^3.20.2", 18 | "axios": "^0.20.0", 19 | "lodash": "^4.17.20", 20 | "normalize-text": "^2.3.1", 21 | "prettier": "^2.1.2", 22 | "vue-apexcharts": "^1.6.0", 23 | "vue-code-highlight": "^0.7.6", 24 | "vue-numeral-filter": "^2.0.0", 25 | "vue-text-highlight": "^2.0.10", 26 | "vuetify": "^2.3.10", 27 | "vuetify-loader": "^1.6.0" 28 | }, 29 | "devDependencies": { 30 | "browser-sync": "^2.26.12", 31 | "browser-sync-webpack-plugin": "^2.0.1", 32 | "cross-env": "^7.0.2", 33 | "laravel-mix": "^5.0.5", 34 | "resolve-url-loader": "^3.1.0", 35 | "sass": "^1.26.10", 36 | "sass-loader": "^10.0.2", 37 | "vue": "^2.6.12", 38 | "vue-template-compiler": "^2.6.12" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Models/Field.php: -------------------------------------------------------------------------------- 1 | belongsTo(Type::class); 26 | } 27 | 28 | public function requests(): HasMany 29 | { 30 | return $this->hasMany(Request::class); 31 | } 32 | 33 | public function sumaryWithClients(array $range) 34 | { 35 | return Client::all() 36 | ->map(function ($client) use ($range) { 37 | $client->metrics = Operation::query() 38 | ->with('field') 39 | ->whereHas('requests', function ($query) use ($client, $range) { 40 | $query->forClient($client)->forField($this)->inRange($range); 41 | }) 42 | ->withCount(['requests as total_requests' => function (Builder $query) use ($client, $range) { 43 | $query->forClient($client)->forField($this)->inRange($range); 44 | }]) 45 | ->get(); 46 | 47 | return $client; 48 | }) 49 | ->reject(fn ($client) => count($client->metrics) == 0) 50 | ->values(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Feature/OperationWithErrorTest.php: -------------------------------------------------------------------------------- 1 | create(); 16 | 17 | $this->schema = File::get(__DIR__ . '/../Utils/Schemas/schema-with-internal-error.graphql'); 18 | } 19 | 20 | public function test_node_with_internal_error() 21 | { 22 | $this->graphQL(' 23 | { 24 | products{ 25 | id 26 | name 27 | badges { 28 | name 29 | } 30 | } 31 | } 32 | '); 33 | 34 | $this->get("/lighthouse-dashboard/errors") 35 | ->assertPropCount("errors", 1) 36 | ->assertPropValue("errors", function ($data) { 37 | $this->assertEquals('internal', $data[0]['category']); 38 | }); 39 | } 40 | 41 | public function test_root_with_internal_error() 42 | { 43 | $this->graphQL(' 44 | { 45 | products{ 46 | id 47 | name 48 | person { 49 | name 50 | } 51 | } 52 | } 53 | '); 54 | 55 | $this->get("/lighthouse-dashboard/errors") 56 | ->assertPropCount("errors", 1) 57 | ->assertPropValue("errors", function ($data) { 58 | $this->assertEquals('internal', $data[0]['category']); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Http/Controllers/OperationController.php: -------------------------------------------------------------------------------- 1 | get(); 17 | 18 | $range = $this->parseRange($request); 19 | $selectedClients = $request->input('clients', $clients->pluck('id')->toArray()); 20 | 21 | $topOperations = Operation::topIn($range, $selectedClients); 22 | $slowlestOperations = Operation::slowIn($range, $selectedClients); 23 | 24 | return inertia('Operations', [ 25 | 'topOperations' => $topOperations, 26 | 'slowlestOperations' => $slowlestOperations, 27 | 'start_date' => $request->input('start_date', 'last month'), 28 | 'range' => $request->input('range'), 29 | 'clients' => $clients, 30 | 'selectedClients' => $selectedClients 31 | ]); 32 | } 33 | 34 | public function show(Operation $operation) 35 | { 36 | $operation->load(['field', 'tracings' => function ($query) { 37 | return $query->with('request.client')->latest('requested_at')->take(50); 38 | }]); 39 | 40 | return inertia('Operation', [ 41 | 'operation' => $operation 42 | ]); 43 | } 44 | 45 | public function sumary(Operation $operation, Request $request) 46 | { 47 | $clients = Client::orderBy('username')->get(); 48 | 49 | $range = $this->parseRange($request); 50 | $selectedClients = $request->input('clients', $clients->pluck('id')->toArray()); 51 | 52 | return $operation->sumaryWithClients($operation, $range, $selectedClients); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Feature/IgnoreClientsTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 18 | } 19 | 20 | public function test_ignore_clients() 21 | { 22 | config(['lighthouse-dashboard.ignore_clients' => ['test-client', 'anonymous']]); 23 | 24 | $client1 = new User(); 25 | $client1->username = 'sales-client'; 26 | 27 | $client2 = new User(); 28 | $client2->username = 'test-client'; 29 | 30 | $this->actingAs($client1)->graphQL(' 31 | { 32 | products{ 33 | id 34 | name 35 | } 36 | } 37 | '); 38 | 39 | $this->actingAs($client2)->graphQL(' 40 | { 41 | products{ 42 | id 43 | name 44 | } 45 | } 46 | '); 47 | 48 | Auth::logout(); 49 | 50 | // Anonymous 51 | $this->graphQL(' 52 | { 53 | products{ 54 | id 55 | name 56 | } 57 | } 58 | '); 59 | 60 | $this->get("/lighthouse-dashboard/errors")->assertPropCount("errors", 0); 61 | 62 | $this->get("/lighthouse-dashboard/operations") 63 | ->assertPropCount("topOperations", 1) 64 | ->assertPropValue("topOperations", function ($data) { 65 | $this->assertEquals($data[0]['total_requests'], 1); 66 | $this->assertEquals($data[0]['field']['name'], 'products'); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robsontenorio/lighthouse-dashboard", 3 | "description": "Dashboard for Laravel Lighthouse GraphQL.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Robson Tenório", 8 | "email": "rrtenorio@gmail.com", 9 | "homepage": "http://github.com/robsontenorio" 10 | } 11 | ], 12 | "homepage": "https://github.com/robsontenorio/lighthouse-dashboard", 13 | "keywords": [ 14 | "laravel", 15 | "graphql", 16 | "lighthouse", 17 | "dashboard", 18 | "analytics", 19 | "metrics" 20 | ], 21 | "require": { 22 | "illuminate/support": "~8|~9", 23 | "inertiajs/inertia-laravel": "^0.2.12|^0.6.9", 24 | "nuwave/lighthouse": "dev-master||^5" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^9.3", 28 | "mockery/mockery": "^1.4", 29 | "nunomaduro/collision": "^5.0", 30 | "spatie/phpunit-watcher": "^1.22", 31 | "orchestra/testbench": "^6.1", 32 | "pestphp/pest": "^0.3.6" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "App\\": "src/", 37 | "Database\\Factories\\": "database/factories/", 38 | "Database\\Seeders\\": "database/seeders/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "test": "./vendor/bin/phpunit", 48 | "test:coverage": "php -dpcov.enabled=1 -dmemory_limit=2048M ./vendor/bin/pest --coverage --coverage-clover=.coverage/clover.xml --coverage-html=.coverage/html", 49 | "test:watch": [ 50 | "Composer\\Config::disableProcessTimeout", 51 | "phpunit-watcher watch < /dev/tty" 52 | ] 53 | }, 54 | "minimum-stability": "dev", 55 | "prefer-stable": true, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "App\\Providers\\LighthouseDashboardServiceProvider" 60 | ] 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /resources/js/layouts/default.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Build package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | php-build: 7 | name: Test with PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | php: [7.4] 12 | laravel: [8.*] 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Cache dependencies 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.composer/cache/files 21 | key: dependencies-composer-${{ hashFiles('composer.json') }} 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php }} 27 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath 28 | coverage: pcov 29 | 30 | - name: Install Composer dependencies 31 | run: composer install --prefer-dist --no-suggest --ansi 32 | 33 | - name: Execute tests 34 | run: composer test:coverage 35 | 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v1 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | file: .coverage/clover.xml 41 | fail_ci_if_error: true 42 | 43 | js-build: 44 | name: "Build frontend assets" 45 | needs: php-build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v2 50 | 51 | - name: Setup Node 52 | uses: actions/setup-node@v2-beta 53 | 54 | - name: Install dependencies 55 | run: yarn install 56 | 57 | # Remove any commited assets before building. 58 | - name: Build frontend production assets 59 | run: | 60 | rm -rf /public/vendor/lighthouse-dashboard/js 61 | rm -rf /public/vendor/lighthouse-dashboard/css 62 | rm public/vendor/lighthouse-dashboard/mix-manifest.json 63 | yarn production 64 | 65 | - name: Commit changes 66 | uses: EndBug/add-and-commit@v4 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | message: "Automated fresh build for frontend assets" 71 | -------------------------------------------------------------------------------- /src/Listeners/ManipulateResultListener.php: -------------------------------------------------------------------------------- 1 | muteTracingResponse($this->result); 19 | } 20 | } 21 | 22 | public function handle(ManipulateResult $result) 23 | { 24 | $this->result = $result; 25 | 26 | // Ignore introspection requests. 27 | if ($this->isIntrospectionRequest()) { 28 | return; 29 | } 30 | 31 | // Silent database failure when bootstraping, but report it. 32 | try { 33 | $client = $this->getClient(); 34 | $schema = Schema::first(); 35 | $payload = request()->json('query'); 36 | } catch (\Throwable $th) { 37 | report($th); 38 | return; 39 | } 40 | 41 | // Ignore clients 42 | if ($this->isIgnoredClient($client)) { 43 | return; 44 | } 45 | 46 | // TODO 47 | if (config('app.env') === 'testing') { 48 | StoreMetrics::dispatchNow($client, $schema, $payload, $result); 49 | } else { 50 | StoreMetrics::dispatchAfterResponse($client, $schema, $payload, $result); 51 | } 52 | } 53 | 54 | private function isIntrospectionRequest() 55 | { 56 | $requestContent = request()->getContent(); 57 | 58 | // TODO 59 | return strstr($requestContent, '__schema'); 60 | } 61 | 62 | private function getClient() 63 | { 64 | $user = Auth::user(); 65 | 66 | // If not authenticated return anonymous user 67 | if (!$user) { 68 | return Client::first(); 69 | } 70 | 71 | $identifer = config('lighthouse-dashboard.client_identifier'); 72 | 73 | return Client::firstOrCreate(['username' => $user->$identifer]); 74 | } 75 | 76 | private function isIgnoredClient(Client $client) 77 | { 78 | $ignoredClients = config('lighthouse-dashboard.ignore_clients'); 79 | 80 | return in_array($client->username, $ignoredClients); 81 | } 82 | 83 | private function muteTracingResponse($result) 84 | { 85 | unset($result->result->extensions['tracing']); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /resources/css/app.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro'); 2 | @import url('https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css'); 3 | 4 | 5 | // TODO use sass variable 6 | .v-application, .v-application .title { 7 | font-family: 'Source Sans Pro'!important; 8 | } 9 | 10 | .row-pointer tbody tr:hover { 11 | cursor: pointer; 12 | } 13 | 14 | .bordered { 15 | border-bottom: 1px solid #efefef; 16 | } 17 | 18 | 19 | .v-application code { 20 | background: none !important; 21 | color: #328c8c !important; 22 | } 23 | 24 | pre[class*="language-"]{ 25 | background: none !important; 26 | } 27 | 28 | 29 | 30 | /* Make clicks pass-through */ 31 | #nprogress { 32 | pointer-events: none; 33 | } 34 | 35 | #nprogress .bar { 36 | background: #29d; 37 | 38 | position: fixed; 39 | z-index: 1031; 40 | top: 0; 41 | left: 0; 42 | 43 | width: 100%; 44 | height: 2px; 45 | } 46 | 47 | /* Fancy blur effect */ 48 | #nprogress .peg { 49 | display: block; 50 | position: absolute; 51 | right: 0px; 52 | width: 100px; 53 | height: 100%; 54 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 55 | opacity: 1.0; 56 | 57 | -webkit-transform: rotate(3deg) translate(0px, -4px); 58 | -ms-transform: rotate(3deg) translate(0px, -4px); 59 | transform: rotate(3deg) translate(0px, -4px); 60 | } 61 | 62 | /* Remove these to get rid of the spinner */ 63 | #nprogress .spinner { 64 | display: block; 65 | position: fixed; 66 | z-index: 1031; 67 | top: 15px; 68 | right: 15px; 69 | } 70 | 71 | #nprogress .spinner-icon { 72 | width: 18px; 73 | height: 18px; 74 | box-sizing: border-box; 75 | 76 | border: solid 2px transparent; 77 | border-top-color: #29d; 78 | border-left-color: #29d; 79 | border-radius: 50%; 80 | 81 | -webkit-animation: nprogress-spinner 400ms linear infinite; 82 | animation: nprogress-spinner 400ms linear infinite; 83 | } 84 | 85 | .nprogress-custom-parent { 86 | overflow: hidden; 87 | position: relative; 88 | } 89 | 90 | .nprogress-custom-parent #nprogress .spinner, 91 | .nprogress-custom-parent #nprogress .bar { 92 | position: absolute; 93 | } 94 | 95 | @-webkit-keyframes nprogress-spinner { 96 | 0% { -webkit-transform: rotate(0deg); } 97 | 100% { -webkit-transform: rotate(360deg); } 98 | } 99 | @keyframes nprogress-spinner { 100 | 0% { transform: rotate(0deg); } 101 | 100% { transform: rotate(360deg); } 102 | } 103 | 104 | 105 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/css/app.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./resources/css/app.scss"],"names":[],"mappings":"6JAKA,qCACI,qCACJ,CAEA,4BACI,cACJ,CAEA,UACI,+BACJ,CAGA,oBAEE,uBAAF,CAGA,0CAJE,yBAKF,CAMA,WACI,mBADJ,CAIE,gBACE,gBAEA,eACA,aACA,MACA,OAEA,WACA,UAHJ,CAOE,gBACE,cACA,kBACA,QACA,YACA,YACA,sCACA,UAIQ,uCAHZ,CAOE,oBACE,cACA,eACA,aACA,SACA,UAFJ,CAKE,yBACE,WACA,YACA,sBAIA,mFACA,kBAEA,wDACQ,+CAJZ,CAOE,yBACE,gBACA,iBAJJ,CAOE,sFAEE,iBAJJ,CAOE,qCACE,GAAO,8BAHT,CAIE,GAAO,+BAAT,CACF,CACE,6BACE,GAAO,sBAGT,CAFE,GAAO,uBAMT,CACF","file":"css/app.css","sourcesContent":["@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro');\n@import url('https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css');\n\n\n// TODO use sass variable\n.v-application, .v-application .title {\n font-family: 'Source Sans Pro'!important;\n}\n\n.row-pointer tbody tr:hover {\n cursor: pointer;\n}\n\n.bordered {\n border-bottom: 1px solid #efefef;\n}\n\n\n.v-application code {\n background: none !important;\n color: #328c8c !important;\n}\n\npre[class*=\"language-\"]{\n background: none !important; \n}\n\n\n\n/* Make clicks pass-through */\n#nprogress {\n pointer-events: none;\n }\n \n #nprogress .bar {\n background: #29d;\n \n position: fixed;\n z-index: 1031;\n top: 0;\n left: 0;\n \n width: 100%;\n height: 2px;\n }\n \n /* Fancy blur effect */\n #nprogress .peg {\n display: block;\n position: absolute;\n right: 0px;\n width: 100px;\n height: 100%;\n box-shadow: 0 0 10px #29d, 0 0 5px #29d;\n opacity: 1.0;\n \n -webkit-transform: rotate(3deg) translate(0px, -4px);\n -ms-transform: rotate(3deg) translate(0px, -4px);\n transform: rotate(3deg) translate(0px, -4px);\n }\n \n /* Remove these to get rid of the spinner */\n #nprogress .spinner {\n display: block;\n position: fixed;\n z-index: 1031;\n top: 15px;\n right: 15px;\n }\n \n #nprogress .spinner-icon {\n width: 18px;\n height: 18px;\n box-sizing: border-box;\n \n border: solid 2px transparent;\n border-top-color: #29d;\n border-left-color: #29d;\n border-radius: 50%;\n \n -webkit-animation: nprogress-spinner 400ms linear infinite;\n animation: nprogress-spinner 400ms linear infinite;\n }\n \n .nprogress-custom-parent {\n overflow: hidden;\n position: relative;\n }\n \n .nprogress-custom-parent #nprogress .spinner,\n .nprogress-custom-parent #nprogress .bar {\n position: absolute;\n }\n \n @-webkit-keyframes nprogress-spinner {\n 0% { -webkit-transform: rotate(0deg); }\n 100% { -webkit-transform: rotate(360deg); }\n }\n @keyframes nprogress-spinner {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n }\n \n\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /tests/Feature/OperationTracingTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 24 | 25 | ProductFactory::times(5)->create(); 26 | 27 | $this->clients = new Collection(); 28 | $this->clients->push(Client::first()); // anonymous 29 | 30 | $moreClients = ClientFactory::times(3)->create(); 31 | $moreClients->each(fn ($client) => $this->clients->push($client)); 32 | } 33 | 34 | /** 35 | * @dataProvider dataset 36 | */ 37 | public function test_operation_tracing($requests, $path, $expect) 38 | { 39 | foreach ($requests as $request) { 40 | $this->customGraphQLRequest() 41 | ->times($request['times']) 42 | ->query($request['query']); 43 | } 44 | 45 | [$type_name, $field_name] = explode('.', $path); 46 | 47 | $type = Type::where('name', $type_name)->first(); 48 | $field = Field::where(['name' => $field_name, 'type_id' => $type->id])->first(); 49 | $operation = Operation::where('field_id', $field->id)->first(); 50 | 51 | $this->get("/lighthouse-dashboard/operations/{$operation->id}") 52 | ->assertPropValue('operation', function ($operation) use ($expect) { 53 | $total_tracings = count($operation['tracings']); 54 | $this->assertEquals($total_tracings, $expect['total_tracings']); 55 | }); 56 | } 57 | 58 | public function dataset() 59 | { 60 | return [ 61 | // CASE 1 62 | [ 63 | 'requests' => [ 64 | [ 65 | "times" => 3, 66 | "query" => '{ categories { id name } }' 67 | ], 68 | [ 69 | "times" => 3, 70 | "query" => '{ products { id name } }' 71 | ], 72 | [ 73 | "times" => 4, 74 | "query" => '{ products { id name category { id name} } }' 75 | ], 76 | ], 77 | 'path' => 'Query.products', 78 | 'expect' => [ 79 | 'total_tracings' => 7 80 | ] 81 | ], 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /resources/js/components/Filters.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 99 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 28 | $this->setUpTestSchema(); 29 | $this->withoutMix(); 30 | 31 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 32 | $this->loadMigrationsFrom(__DIR__ . '/Utils/Database/Migrations'); 33 | } 34 | 35 | protected function getPackageProviders($app) 36 | { 37 | return [ 38 | LighthouseServiceProvider::class, // Bootstrap "nuwave/lighthouse" 39 | TracingServiceProvider::class, // Enable tracing from "nuwave/lighthouse" 40 | LighthouseDashboardServiceProvider::class, // this package 41 | ]; 42 | } 43 | 44 | protected function getEnvironmentSetUp($app) 45 | { 46 | $this->setupConnections($app); 47 | $this->setupLighthouse($app); 48 | } 49 | 50 | /** 51 | * Settings from "nuwave/lighthouse" 52 | */ 53 | private function setupLighthouse($app) 54 | { 55 | // Use testing namespaces 56 | $app['config']->set('lighthouse.namespaces', [ 57 | 'models' => [ 58 | 'Tests\\Utils\\Models' 59 | ] 60 | ]); 61 | 62 | // Disable cache because we change SDL a lot. 63 | $app['config']->set('lighthouse.cache.enable', false); 64 | } 65 | 66 | /** 67 | * Lighthouse Dashboard database connection 68 | */ 69 | private function setupConnections($app) 70 | { 71 | // Dashboard database connection 72 | $app['config']->set('database.connections.dashboard', [ 73 | 'driver' => 'sqlite', 74 | 'database' => ':memory:', 75 | 'prefix' => '', 76 | ]); 77 | 78 | $app['config']->set('database.default', 'dashboard'); 79 | } 80 | 81 | /** 82 | * Allow to switch between schemas on same test method. 83 | */ 84 | protected function rebuildTestSchema() 85 | { 86 | // TODO not working. Why? 87 | $this->app->extend(SchemaSourceProvider::class, fn () => new TestSchemaProvider($this->schema)); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Providers/LighthouseDashboardServiceProvider.php: -------------------------------------------------------------------------------- 1 | setupInertia(); 22 | 23 | $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'lighthouse-dashboard'); 24 | $this->loadFactoriesFrom(__DIR__ . '/../../database/factories'); 25 | $this->loadRoutesFrom(__DIR__ . '/../routes.php'); 26 | 27 | // Publishing is only necessary when using the CLI. 28 | if ($this->app->runningInConsole()) { 29 | $this->bootForConsole(); 30 | } 31 | } 32 | 33 | public function register() 34 | { 35 | // Register Tracing feature from "nuwave/lighthouse" 36 | $this->app->register(TracingServiceProvider::class); 37 | 38 | $this->mergeConfigFrom(__DIR__ . '/../../config/lighthouse-dashboard.php', 'lighthouse-dashboard'); 39 | 40 | // Register the service the package provides. 41 | $this->app->singleton('LighthouseDashboard', function ($app) { 42 | return new LighthouseDashboard; 43 | }); 44 | 45 | // Register Event listener 46 | Event::listen(ManipulateResult::class, ManipulateResultListener::class); 47 | } 48 | 49 | public function provides() 50 | { 51 | return ['lighthouse-dashboard']; 52 | } 53 | 54 | protected function bootForConsole() 55 | { 56 | // Publishing the configuration file. 57 | $this->publishes([ 58 | __DIR__ . '/../../config/lighthouse-dashboard.php' => config_path('lighthouse-dashboard.php'), 59 | ], 'lighthouse-dashboard'); 60 | 61 | // Publishing assets. 62 | $this->publishes([ 63 | __DIR__ . '/../../public/vendor/lighthouse-dashboard' => public_path('vendor/lighthouse-dashboard'), 64 | ], ['lighthouse-dashboard', 'lighthouse-dashboard-assets']); 65 | 66 | // Registering package commands. 67 | $this->commands([ 68 | PublishCommand::class, 69 | PublishAssetsCommand::class, 70 | MigrateCommand::class, 71 | SeedCommand::class 72 | ]); 73 | } 74 | 75 | private function setupInertia() 76 | { 77 | Inertia::version(function () { 78 | // TODO 79 | if (config('app.env') == 'testing') { 80 | return; 81 | } 82 | 83 | return md5_file(public_path('vendor/lighthouse-dashboard/mix-manifest.json')); 84 | }); 85 | 86 | Inertia::setRootView('lighthouse-dashboard::app'); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Feature/OperationsWithClientFilterTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 16 | } 17 | 18 | public function test_top_operations_with_clients_filters() 19 | { 20 | // Anonymous 21 | $this->customGraphQLRequest() 22 | ->times(3) 23 | ->query(' 24 | { 25 | products{ 26 | id 27 | name 28 | } 29 | } 30 | '); 31 | 32 | $client1 = ClientFactory::new()->create(); 33 | $client2 = ClientFactory::new()->create(); 34 | 35 | $this->customGraphQLRequest() 36 | ->times(3) 37 | ->forClient($client1) 38 | ->query(' 39 | { 40 | products{ 41 | id 42 | name 43 | } 44 | } 45 | '); 46 | 47 | $this->customGraphQLRequest() 48 | ->times(3) 49 | ->forClient($client2) 50 | ->query(' 51 | { 52 | products{ 53 | id 54 | name 55 | } 56 | } 57 | '); 58 | 59 | $this->get("/lighthouse-dashboard/operations") 60 | ->assertPropCount("topOperations", 1) 61 | ->assertPropValue("topOperations", function ($data) { 62 | $this->assertEquals($data[0]['total_requests'], 9); 63 | $this->assertEquals($data[0]['field']['name'], 'products'); 64 | }); 65 | 66 | $this->get("/lighthouse-dashboard/operations?clients[]={$client1->id}") 67 | ->assertPropCount("topOperations", 1) 68 | ->assertPropValue("topOperations", function ($data) { 69 | $this->assertEquals($data[0]['total_requests'], 3); 70 | $this->assertEquals($data[0]['field']['name'], 'products'); 71 | }); 72 | 73 | $this->get("/lighthouse-dashboard/operations?clients[]={$client1->id}&clients[]={$client2->id}") 74 | ->assertPropCount("topOperations", 1) 75 | ->assertPropValue("topOperations", function ($data) { 76 | $this->assertEquals($data[0]['total_requests'], 6); 77 | $this->assertEquals($data[0]['field']['name'], 'products'); 78 | }); 79 | 80 | // Client ID = 1 anonymous 81 | $this->get("/lighthouse-dashboard/operations?clients[]=1") 82 | ->assertPropCount("topOperations", 1) 83 | ->assertPropValue("topOperations", function ($data) { 84 | $this->assertEquals($data[0]['total_requests'], 3); 85 | $this->assertEquals($data[0]['field']['name'], 'products'); 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /resources/js/components/OperationSumary.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | -------------------------------------------------------------------------------- /tests/Unit/ManipulateResultListenerTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema.graphql'); 20 | } 21 | 22 | public function test_introspection_queries_does_not_dispatch_store_metrics_job() 23 | { 24 | $this->introspect(); 25 | 26 | Bus::assertNotDispatched(StoreMetrics::class); 27 | } 28 | 29 | public function test_it_dispatch_store_metrics_job() 30 | { 31 | $this->graphQL(' 32 | { 33 | products{ 34 | id 35 | name 36 | } 37 | } 38 | '); 39 | 40 | Bus::assertDispatched(StoreMetrics::class); 41 | } 42 | 43 | public function test_uses_anonymous_client_if_there_is_no_authenticated_user() 44 | { 45 | $this->graphQL(' 46 | { 47 | products{ 48 | id 49 | name 50 | } 51 | } 52 | '); 53 | 54 | Bus::assertDispatched(function (StoreMetrics $job) { 55 | return $job->client->username == 'anonymous'; 56 | }); 57 | } 58 | 59 | public function test_get_username_of_client_if_authenticated() 60 | { 61 | $user = new User(); 62 | $user->username = 'marina'; 63 | 64 | $this->actingAs($user)->graphQL(' 65 | { 66 | products{ 67 | id 68 | name 69 | } 70 | } 71 | '); 72 | 73 | Bus::assertDispatched(function (StoreMetrics $job) { 74 | return $job->client->username == 'marina'; 75 | }); 76 | } 77 | 78 | public function test_username_respects_configuration() 79 | { 80 | config(['lighthouse-dashboard.client_identifier' => 'nickname']); 81 | 82 | $user = new User(); 83 | $user->nickname = 'amanda'; 84 | 85 | $this->actingAs($user)->graphQL(' 86 | { 87 | products{ 88 | id 89 | name 90 | } 91 | } 92 | '); 93 | 94 | Bus::assertDispatched(function (StoreMetrics $job) { 95 | return $job->client->username == 'amanda'; 96 | }); 97 | } 98 | 99 | public function test_silent_database_failure_when_bootstraping() 100 | { 101 | // Force dashboard settings to a not configured connection 102 | config(['lighthouse-dashboard.connection' => 'mysql']); 103 | 104 | $this->graphQL(' 105 | { 106 | products{ 107 | id 108 | name 109 | } 110 | } 111 | ')->assertJsonPath('errors', null); 112 | 113 | Bus::assertNotDispatched(StoreMetrics::class); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /resources/js/components/FieldSumary.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | -------------------------------------------------------------------------------- /src/Models/Request.php: -------------------------------------------------------------------------------- 1 | 'int' 22 | ]; 23 | 24 | public function getConnectionName() 25 | { 26 | return config('lighthouse-dashboard.connection'); 27 | } 28 | 29 | public function client(): BelongsTo 30 | { 31 | return $this->belongsTo(Client::class); 32 | } 33 | 34 | public function field(): BelongsTo 35 | { 36 | return $this->belongsTo(Field::class); 37 | } 38 | 39 | public function operation(): BelongsTo 40 | { 41 | return $this->belongsTo(Operation::class); 42 | } 43 | 44 | public function tracing(): HasOne 45 | { 46 | return $this->hasOne(Tracing::class); 47 | } 48 | 49 | public function errors(): HasMany 50 | { 51 | return $this->hasMany(Error::class); 52 | } 53 | 54 | public function scopeIsOperation(Builder $query): Builder 55 | { 56 | return $query->whereNotNull('duration'); 57 | } 58 | 59 | public function scopeInRange(Builder $query, array $range): Builder 60 | { 61 | return $query->whereBetween('requested_at', $range); 62 | } 63 | 64 | public function scopeForOperation(Builder $query, Operation $operation): Builder 65 | { 66 | return $query->where('operation_id', $operation->id)->isOperation(); 67 | } 68 | 69 | public function scopeForClient(Builder $query, Client $client): Builder 70 | { 71 | return $query->where('client_id', $client->id); 72 | } 73 | 74 | public function scopeForClients(Builder $query, array $clients = []): Builder 75 | { 76 | return $query->whereIn('client_id', $clients); 77 | } 78 | 79 | public function scopeForField(Builder $query, Field $field): Builder 80 | { 81 | return $query->where('field_id', $field->id); 82 | } 83 | 84 | public static function seriesIn(array $range, array $clients = []) 85 | { 86 | $requests_series = Request::query() 87 | ->selectRaw('DATE(requested_at) as x, count(*) as y') 88 | ->isOperation() 89 | ->inRange($range) 90 | ->forClients($clients) 91 | ->groupBy('x') 92 | ->orderBy('x') 93 | ->get(); 94 | 95 | if ($requests_series->count() == 0) { 96 | return []; 97 | } 98 | 99 | // Fill empty dates in range 100 | $period = CarbonPeriod::between($requests_series->first()->x, $range['end_date']); 101 | $series = collect(); 102 | 103 | foreach ($period as $date) { 104 | if ($item = $requests_series->firstWhere('x', $date->toDateString())) { 105 | $series->add($item); 106 | continue; 107 | } 108 | 109 | $series->add(['x' => $date->toDateString(), 'y' => null]); 110 | } 111 | 112 | return $series; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Utils/Traits/MakeCustomGraphQLRequests.php: -------------------------------------------------------------------------------- 1 | times = $times; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Set when this request was made. 51 | * 52 | * @param string $string Any valid string to be parsed by Carbon. 53 | */ 54 | protected function withDateTime(string $string = null) 55 | { 56 | // Always travel back to "now" before travelling time. 57 | $this->travelBack(); 58 | 59 | if ($string) { 60 | $this->travelTo(Carbon::parse($string)); 61 | } 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Set specific duration for a operation. 68 | * 69 | * @param int $nanoseconds 70 | */ 71 | protected function withDuration(int $nanoseconds) 72 | { 73 | $this->duration = $nanoseconds; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * Set request as specific Client. 80 | */ 81 | protected function forClient(Client $client) 82 | { 83 | // Actually a Client will be parsed from authenticated User. 84 | $user = new User(); 85 | $user->username = $client->username; 86 | 87 | $this->actingAs($user); 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Make a graphQL query request. 94 | * 95 | * @param string $query A graphQL query. 96 | */ 97 | protected function query(string $query) 98 | { 99 | // remember latest request before make new requests 100 | $latestRequest = Request::isOperation()->latest('id')->first(); 101 | 102 | for ($i = 1; $i <= $this->times; $i++) { 103 | $this->graphQL($query); 104 | } 105 | 106 | if (isset($this->duration)) { 107 | $this->updateDurationAfter($latestRequest); 108 | } 109 | 110 | // Always travel back to "now" after requests 111 | $this->travelBack(); 112 | } 113 | 114 | /** 115 | * Update duration considering latest request mark point 116 | */ 117 | protected function updateDurationAfter(?Request $latestRequest) 118 | { 119 | $operation = Request::isOperation()->latest('id')->first()->operation; 120 | 121 | $operation->requests() 122 | ->isOperation() 123 | ->when($latestRequest, function ($query) use ($latestRequest) { 124 | $query->where('id', '>', $latestRequest->id); 125 | }) 126 | ->update([ 127 | 'duration' => $this->duration 128 | ]); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/Feature/FieldSumaryTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 23 | 24 | ProductFactory::times(5)->create(); 25 | 26 | $this->clients = new Collection(); 27 | $this->clients->push(Client::first()); // anonymous 28 | 29 | $moreClients = ClientFactory::times(3)->create(); 30 | $moreClients->each(fn ($client) => $this->clients->push($client)); 31 | } 32 | 33 | /** 34 | * @dataProvider dataset 35 | */ 36 | public function test_field_sumary_filters($requests, $path, $query_string, $expect) 37 | { 38 | foreach ($requests as $request) { 39 | $client = $this->clients->where('id', $request['client_id'])->first(); 40 | 41 | $this->customGraphQLRequest() 42 | ->withDateTime($request['dateTime']) 43 | ->forClient($client) 44 | ->times($request['times']) 45 | ->query($request['query']); 46 | } 47 | 48 | [$type_name, $field_name] = explode('.', $path); 49 | 50 | $type = Type::where('name', $type_name)->first(); 51 | $field = Field::where(['name' => $field_name, 'type_id' => $type->id])->first(); 52 | 53 | $clients = $this->get("/lighthouse-dashboard/fields/{$field->id}/sumary?{$query_string}")->json(); 54 | 55 | $clients = collect($clients)->pluck('metrics', 'id')->map(function ($item) { 56 | return collect($item)->pluck('total_requests', 'field.name'); 57 | })->toArray(); 58 | 59 | $this->assertEquals($clients, $expect['clients']); 60 | } 61 | 62 | public function dataset() 63 | { 64 | return [ 65 | // CASE 1 66 | [ 67 | 'requests' => [ 68 | [ 69 | "dateTime" => "today", 70 | "client_id" => 1, // "1" is anonymous 71 | "times" => 3, 72 | "query" => '{ categories { id name } }' 73 | ], 74 | [ 75 | "dateTime" => "today", 76 | "client_id" => 2, 77 | "times" => 3, 78 | "query" => '{ products { id name } }' 79 | ], 80 | [ 81 | "dateTime" => "yesterday", 82 | "client_id" => 1, 83 | "times" => 4, 84 | "query" => '{ products { id name category { id name} } }' 85 | ], 86 | ], 87 | 'path' => 'Category.name', 88 | 'query_string' => "start_date=yesterday", 89 | 'expect' => [ 90 | 'clients' => [ 91 | 1 => [ 92 | 'categories' => 3, 93 | 'products' => 4 94 | ], 95 | ] 96 | ] 97 | ], 98 | ]; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Actions/SyncGraphQLSchema.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 29 | $this->graphQLSchema = app(GraphQL::class)->prepSchema(); 30 | $this->schemaString = SchemaPrinter::doPrint($this->graphQLSchema); 31 | } 32 | 33 | public static function run(Schema $schema) 34 | { 35 | $self = new self($schema); 36 | 37 | $self->sync(); 38 | } 39 | 40 | private function sync() 41 | { 42 | $current_hash = md5($this->schemaString); 43 | 44 | if ($this->schema->hash === $current_hash) { 45 | return; 46 | } 47 | 48 | $this->schema->update([ 49 | 'hash' => $current_hash, 50 | 'schema' => $this->schemaString 51 | ]); 52 | 53 | $this->syncTypes(); 54 | } 55 | 56 | private function syncTypes() 57 | { 58 | $introspectedTypes = $this->getIntrospectedSchemaTypes(); 59 | 60 | foreach ($introspectedTypes as $introspectedType) { 61 | $type = Type::updateOrCreate( 62 | [ 63 | 'schema_id' => $this->schema->id, 64 | 'name' => $introspectedType->name, 65 | ], 66 | [ 67 | 'description' => $introspectedType->description 68 | ] 69 | ); 70 | 71 | $this->syncFieldsBetween($type, $introspectedType); 72 | } 73 | } 74 | 75 | private function syncFieldsBetween(Type $type, ObjectType $introspectedType) 76 | { 77 | $fields = $introspectedType->getFields(); 78 | 79 | foreach ($fields as $field) { 80 | Field::updateOrCreate( 81 | [ 82 | 'type_id' => $type->id, 83 | 'name' => $field->name, 84 | 'type_def' => (string) $field->getType(), 85 | ], 86 | [ 87 | 'description' => $field->description, 88 | 'args' => $this->formatFieldArgs($field->args) 89 | ] 90 | ); 91 | } 92 | } 93 | 94 | // TODO: make it work from Lighthouse, not from Webonyx 95 | private function getIntrospectedSchemaTypes() 96 | { 97 | $internalTypes = DefinitionType::getStandardTypes() + Introspection::getTypes(); 98 | $allTypes = collect($this->graphQLSchema->getTypeMap())->reject(fn ($type) => !$type instanceof ObjectType); 99 | 100 | return Arr::except($allTypes, collect($internalTypes)->keys()->toArray()); 101 | } 102 | 103 | // TODO: use json payload, then format it on frontend 104 | private function formatFieldArgs(array $args = []) 105 | { 106 | return collect($args) 107 | ->transform(function ($arg) { 108 | return '
' . $arg->name . ': ' . (string) $arg->getType() . '
'; 109 | }) 110 | ->implode(' '); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/4.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[4],{"2lzZ":function(t,e,a){"use strict";var r=a("esU9");a.n(r).a},"6Wpc":function(t,e,a){var r=a("BK6a");"string"==typeof r&&(r=[[t.i,r,""]]);var n={hmr:!0,transform:void 0,insertInto:void 0};a("aET+")(r,n);r.locals&&(t.exports=r.locals)},BK6a:function(t,e,a){(t.exports=a("I1BE")(!1)).push([t.i,".payload[data-v-c0e6c0ee]{max-width:500px}",""])},KfSH:function(t,e,a){(t.exports=a("I1BE")(!1)).push([t.i,".scroll[data-v-6cf25217]{overflow-y:auto!important}",""])},NdDW:function(t,e,a){"use strict";var r=a("6Wpc");a.n(r).a},esU9:function(t,e,a){var r=a("KfSH");"string"==typeof r&&(r=[[t.i,r,""]]);var n={hmr:!0,transform:void 0,insertInto:void 0};a("aET+")(r,n);r.locals&&(t.exports=r.locals)},gTSs:function(t,e,a){"use strict";a.r(e);var r=a("02zI"),n={props:["execution"],data:function(){return{height:50}}},s=(a("2lzZ"),a("KHd+")),i=Object(s.a)(n,(function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("v-card",{staticClass:"text-truncate my-5 scroll",attrs:{outlined:"","max-height":"400"}},[a("v-card-subtitle",{staticClass:"font-weight-bold"},[t._v("TRACING")]),t._v(" "),a("v-card-text",t._l(t.execution.resolvers,(function(e,r){return a("div",{key:r},[a("v-row",{attrs:{"no-gutters":""}},[a("v-col",[t._v(t._s(e.path.join(".")))]),t._v(" "),a("v-col",{staticClass:"text-right"},[t._v(t._s(t._f("milliseconds")(e.duration)))])],1)],1)})),0)],1)}),[],!1,null,"6cf25217",null).exports,o=a("MlsZ"),l=a("azY9"),c={props:["operation"],components:{VueCodeHighlight:r.a,TracingExecution:i},data:function(){return{table:{headers:[{text:"Arguments",value:"arguments",sortable:!1},{text:"Client",value:"request.client.username",sortable:!1},{text:"Requested at",value:"start_time",sortable:!1},{text:"Duration",value:"duration",sortable:!1}]}}},methods:{extractPayloadArgs:function(t){var e=/\(([^)]+)\)/.exec(t);return e?"("+e[1]+")":"-"},payloadPretty:function(t){return o.format(t,{parser:"graphql",plugins:[l]})},back:function(){window.history.back()}}},u=(a("NdDW"),Object(s.a)(c,(function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",[a("v-app-bar",{attrs:{app:"",color:"white",elevation:"1"}},[a("h2",[a("v-icon",{attrs:{left:"",color:"black"}},[t._v("mdi-pulse")]),t._v("Operations")],1)]),t._v(" "),a("v-bottom-sheet",{attrs:{value:!0,persistent:"",fullscreen:"",scrollable:""}},[a("v-card",{staticClass:"px-5",attrs:{tile:""}},[a("v-card-title",[a("h3",[a("v-icon",{attrs:{left:"",color:"black"}},[t._v("mdi-pulse")]),t._v("\n "+t._s(t.operation.field.name)+"\n ")],1),t._v(" "),a("v-spacer"),t._v(" "),a("v-btn",{attrs:{icon:""},on:{click:function(e){return t.back()}}},[a("v-icon",[t._v("mdi-close")])],1)],1),t._v(" "),a("v-card-subtitle",{staticClass:"pt-3 mb-5 bordered"},[t._v("\n Listing latest 50 tracings.\n ")]),t._v(" "),a("v-card-text",[a("v-data-table",{staticClass:"elevation-3",attrs:{headers:t.table.headers,items:t.operation.tracings,"items-per-page":50,"hide-default-footer":"","show-expand":""},scopedSlots:t._u([{key:"item.arguments",fn:function(e){var r=e.item;return[a("div",{directives:[{name:"highlight",rawName:"v-highlight"}]},[a("pre",{staticClass:"language-graphql ml-n5 text-truncate payload"},[a("code",[t._v(t._s(t.extractPayloadArgs(r.payload)))])])])]}},{key:"item.duration",fn:function(e){var a=e.item;return[t._v(t._s(t._f("milliseconds")(a.duration)))]}},{key:"expanded-item",fn:function(e){var r=e.headers,n=e.item;return[a("td",{attrs:{colspan:r.length}},[a("v-row",[a("v-col",[a("div",{directives:[{name:"highlight",rawName:"v-highlight"}]},[a("pre",{staticClass:"language-graphql"},[a("code",[t._v(t._s(t.payloadPretty(n.payload)))])])])]),t._v(" "),a("v-col",[a("tracing-execution",{attrs:{execution:n.execution.execution}})],1)],1)],1)]}}])})],1)],1)],1)],1)}),[],!1,null,"c0e6c0ee",null));e.default=u.exports}}]); 2 | //# sourceMappingURL=4.js.map?id=6f733823b883c81ab843 -------------------------------------------------------------------------------- /resources/js/pages/Operation.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 109 | -------------------------------------------------------------------------------- /src/Models/Operation.php: -------------------------------------------------------------------------------- 1 | belongsTo(Field::class); 26 | } 27 | 28 | // TODO include "durationNotNull" because it will be always request for a operation 29 | public function requests(): HasMany 30 | { 31 | return $this->hasMany(Request::class); 32 | } 33 | 34 | public function tracings(): HasManyThrough 35 | { 36 | return $this->hasManyThrough(Tracing::class, Request::class); 37 | } 38 | 39 | public function errors(): HasManyThrough 40 | { 41 | return $this->hasManyThrough(Error::class, Request::class); 42 | } 43 | 44 | public static function topIn(array $range, array $clients = []) 45 | { 46 | return Operation::query() 47 | ->with('field') 48 | ->withCount(['requests as total_requests' => function ($query) use ($range, $clients) { 49 | return $query->forClients($clients)->isOperation()->inRange($range); 50 | }]) 51 | ->withCount(['requests as total_errors' => function ($query) use ($range, $clients) { 52 | return $query->forClients($clients)->isOperation()->inRange($range)->whereHas('errors'); 53 | }]) 54 | ->orderByDesc('total_requests') 55 | ->take(10) 56 | ->get(); 57 | } 58 | 59 | public static function slowIn(array $range, array $clients = []) 60 | { 61 | return Operation::query() 62 | ->with('field') 63 | ->whereHas('requests', function ($query) use ($range, $clients) { 64 | return $query->forClients($clients)->isOperation()->inRange($range); 65 | }) 66 | ->get() 67 | ->map(function (Operation $operation) use ($range, $clients) { 68 | // TODO 69 | $operation->average_duration = $operation->getAverageDurationIn($range, $clients); 70 | $operation->latest_duration = $operation->getLatestDurationIn($range, $clients); 71 | 72 | return $operation; 73 | }) 74 | ->sortByDesc('average_duration') 75 | ->take(10) 76 | ->values(); 77 | } 78 | 79 | public function sumaryWithClients(Operation $operation, array $range, array $clients = []) 80 | { 81 | return Client::query() 82 | ->whereHas('requests', function ($query) use ($operation, $range, $clients) { 83 | $query->forClients($clients)->forOperation($operation)->inRange($range); 84 | }) 85 | ->withCount(['requests as total_requests' => function ($query) use ($operation, $range, $clients) { 86 | $query->forClients($clients)->forOperation($operation)->inRange($range); 87 | }]) 88 | ->get(); 89 | } 90 | 91 | private function getAverageDurationIn(array $range) 92 | { 93 | return (int) $this->requests()->inRange($range)->isOperation()->avg('duration'); 94 | } 95 | 96 | private function getLatestDurationIn(array $range) 97 | { 98 | return (int) $this->requests()->inRange($range)->isOperation()->latest('requested_at')->first()->duration; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /database/seeders/LighthouseDashboardSeeder.php: -------------------------------------------------------------------------------- 1 | command->info('Preparing random schema ...'); 25 | 26 | $schema = Schema::first(); 27 | 28 | $clients = Client::factory()->times(10)->create(); 29 | 30 | $queryType = Type::factory() 31 | ->ofQueryType() 32 | ->has(Field::factory()->count(50)) 33 | ->create([ 34 | 'schema_id' => $schema->id 35 | ]); 36 | 37 | Type::factory() 38 | ->count(150) 39 | ->has(Field::factory()->count(5)) 40 | ->create([ 41 | 'schema_id' => $schema->id 42 | ]); 43 | 44 | $queryType->fields()->each(function ($field) { 45 | Operation::factory()->create(['field_id' => $field]); 46 | }); 47 | 48 | $operations = Operation::all(); 49 | $fields = Field::all(); 50 | 51 | 52 | $this->command->info("Preparing chunk with {$this->times} requests ...\n"); 53 | $bar = $this->command->getOutput()->createProgressBar($this->times); 54 | $bar->start(); 55 | 56 | for ($i = 0; $i < $this->times; $i++) { 57 | $operation = $operations->random(); 58 | $client = $clients->random(); 59 | $field = $fields->random(); 60 | $requested_at = $this->makeFaker()->dateTimeBetween('last month'); 61 | $duration = $this->makeFaker()->randomNumber(8); 62 | 63 | $operation_requests[] = [ 64 | 'field_id' => $field->id, 65 | 'operation_id' => $operation->id, 66 | 'client_id' => $client->id, 67 | 'requested_at' => $requested_at, 68 | 'duration' => $this->makeFaker()->randomElement([null, $duration]) 69 | ]; 70 | 71 | $bar->advance(); 72 | 73 | // Log operation 74 | // Request::factory() 75 | // ->has(Tracing::factory()) 76 | // ->create([ 77 | // 'field_id' => $operation->field_id, 78 | // 'operation_id' => $operation, 79 | // 'client_id' => $client, 80 | // 'requested_at' => $requested_at, 81 | // ]); 82 | 83 | // // Log fields of operation 84 | // $operation->field->type 85 | // ->fields() 86 | // ->each(fn ($field) => Request::factory()->times(3)->create([ 87 | // 'field_id' => $field, 88 | // 'operation_id' => $operation, 89 | // 'client_id' => $client, 90 | // 'requested_at' => $requested_at, 91 | // 'duration' => null 92 | // ])); 93 | 94 | } 95 | 96 | $bar->finish(); 97 | 98 | $this->command->info("\n\nSeeding ...\n"); 99 | 100 | $bar = $this->command->getOutput()->createProgressBar($this->times); 101 | $bar->start(); 102 | 103 | $chunks = array_chunk($operation_requests, 10000); 104 | 105 | foreach ($chunks as $chunk) { 106 | Request::insert($chunk); 107 | // Tracing::insert($chunk['tracings']); 108 | $bar->advance(10000); 109 | } 110 | 111 | $bar->finish(); 112 | $this->command->info("\n"); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /resources/js/pages/Welcome.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | -------------------------------------------------------------------------------- /tests/Feature/WelcomeStatisticsTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 21 | 22 | ProductFactory::times(5)->create(); 23 | 24 | $this->clients = new Collection(); 25 | $this->clients->push(Client::first()); // anonymous 26 | 27 | $marina = ClientFactory::new()->create(['username' => 'marina']); 28 | $giovanna = ClientFactory::new()->create(['username' => 'giovanna']); 29 | $amanda = ClientFactory::new()->create(['username' => 'amanda']); 30 | 31 | $this->clients->push($marina, $giovanna, $amanda); 32 | } 33 | 34 | /** 35 | * @dataProvider dataset 36 | */ 37 | public function test_operation_tracing($requests, $query, $expect) 38 | { 39 | foreach ($requests as $request) { 40 | $client = $this->clients->where('username', $request['client'])->first(); 41 | 42 | $this->customGraphQLRequest() 43 | ->forClient($client) 44 | ->withDateTime($request['dateTime']) 45 | ->times($request['times']) 46 | ->query($request['query']); 47 | } 48 | 49 | $this->get("/lighthouse-dashboard/?{$query}") 50 | ->assertPropValue('client_series', function ($data) use ($expect) { 51 | $this->assertEqualsCanonicalizing($data, $expect['clients']); 52 | }) 53 | ->assertPropValue('requests_series', function ($data) use ($expect) { 54 | $this->assertEqualsCanonicalizing($data, $expect['requests']); 55 | }); 56 | } 57 | 58 | public function dataset() 59 | { 60 | return [ 61 | // CASE 1 62 | [ 63 | 'requests' => [ 64 | [ 65 | "dateTime" => "2020-09-27", 66 | "client" => 'anonymous', 67 | "times" => 3, 68 | "query" => '{ categories { id name } }' 69 | ], 70 | [ 71 | "dateTime" => "2020-09-27", 72 | "client" => 'marina', 73 | "times" => 5, 74 | "query" => '{ products { id name } }' 75 | ], 76 | [ 77 | "dateTime" => "2020-09-26", 78 | "client" => 'giovanna', 79 | "times" => 11, 80 | "query" => '{ products { id name} }' 81 | ], 82 | ], 83 | 'query' => 'start_date=in custom range&range[]=2020-09-26&range[]=2020-09-27', 84 | 'expect' => [ 85 | 'clients' => [ 86 | [ 87 | 'x' => 'anonymous', 88 | 'y' => 3 89 | ], 90 | [ 91 | 'x' => 'marina', 92 | 'y' => 5 93 | ], 94 | [ 95 | 'x' => 'giovanna', 96 | 'y' => 11 97 | ], 98 | [ 99 | 'x' => 'amanda', 100 | 'y' => 0 101 | ] 102 | ], 103 | 'requests' => [ 104 | [ 105 | 'x' => '2020-09-26', 106 | 'y' => 11, 107 | ], 108 | [ 109 | 'x' => '2020-09-27', 110 | 'y' => 8, 111 | ] 112 | ] 113 | 114 | ] 115 | ], 116 | ]; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/Feature/OperationSumaryTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 20 | 21 | $this->clients = new Collection(); 22 | $this->clients->push(Client::first()); // anonymous 23 | 24 | $moreClients = ClientFactory::times(3)->create(); 25 | $moreClients->each(fn ($client) => $this->clients->push($client)); 26 | } 27 | 28 | /** 29 | * @dataProvider dataset 30 | */ 31 | public function test_operation_sumary_filters($requests, $operation_id, $query_string, $expect) 32 | { 33 | foreach ($requests as $request) { 34 | $client = $this->clients->where('id', $request['client_id'])->first(); 35 | 36 | $this->customGraphQLRequest() 37 | ->withDateTime($request['dateTime']) 38 | ->forClient($client) 39 | ->times($request['times']) 40 | ->query($request['query']); 41 | } 42 | 43 | $clients = $this->get("/lighthouse-dashboard/operations/{$operation_id}/sumary?{$query_string}")->json(); 44 | 45 | foreach ($clients as $key => $client) { 46 | $this->assertEquals($client['id'], $expect['clients'][$key]['id']); 47 | $this->assertEquals($client['total_requests'], $expect['clients'][$key]['total_requests']); 48 | } 49 | } 50 | 51 | public function dataset() 52 | { 53 | return [ 54 | // CASE 1 55 | [ 56 | 'requests' => [ 57 | [ 58 | "dateTime" => "today", 59 | "client_id" => 1, // "1" is anonymous 60 | "times" => 3, 61 | "query" => '{ categories {id name} }' 62 | ], 63 | [ 64 | "dateTime" => "yesterday", 65 | "client_id" => 2, 66 | "times" => 4, 67 | "query" => '{ categories {id name} }' 68 | ], 69 | ], 70 | 'operation_id' => 1, 71 | 'query_string' => "start_date=yesterday", 72 | 'expect' => [ 73 | 'clients' => [ 74 | [ 75 | 'id' => 1, 76 | 'total_requests' => 3 77 | ], 78 | [ 79 | 'id' => 2, 80 | 'total_requests' => 4 81 | ] 82 | ] 83 | ] 84 | ], 85 | // CASE 2 86 | [ 87 | 'requests' => [ 88 | [ 89 | "dateTime" => "today", 90 | "client_id" => 1, // "1" is anonymous 91 | "times" => 3, 92 | "query" => '{ categories {id name} }' 93 | ], 94 | [ 95 | "dateTime" => "yesterday", 96 | "client_id" => 2, 97 | "times" => 4, 98 | "query" => '{ categories {id name} }' 99 | ], 100 | [ 101 | "dateTime" => "yesterday", 102 | "client_id" => 3, 103 | "times" => 5, 104 | "query" => '{ categories {id name} }' 105 | ], 106 | ], 107 | 'operation_id' => 1, 108 | 'query_string' => "start_date=yesterday&clients[]=3", 109 | 'expect' => [ 110 | 'clients' => [ 111 | [ 112 | 'id' => 3, 113 | 'total_requests' => 5 114 | ] 115 | ] 116 | ] 117 | ], 118 | ]; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/Feature/SyncGraphQLSchemaTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema.graphql'); 19 | 20 | $this->graphQL(' 21 | { 22 | products{ 23 | id 24 | name 25 | } 26 | } 27 | '); 28 | } 29 | 30 | public function test_create_schema_on_first_request() 31 | { 32 | $this->assertEquals(Type::count(), 3); 33 | 34 | $this->assertHasType([ 35 | 'name' => 'Query', 36 | ])->withFields([ 37 | [ 38 | 'name' => 'products', 39 | 'description' => 'List all products', 40 | 'type_def' => '[Product]' 41 | ] 42 | ]); 43 | 44 | $this->assertHasType([ 45 | 'name' => 'Color', 46 | 'description' => 'A beautiful color', 47 | ])->withFields([ 48 | [ 49 | 'name' => 'id', 50 | 'description' => null, 51 | 'type_def' => 'ID!' 52 | ], 53 | [ 54 | 'name' => 'name', 55 | 'description' => null, 56 | 'type_def' => 'String!' 57 | ], 58 | ]); 59 | 60 | $this->assertHasType([ 61 | 'name' => 'Product', 62 | 'description' => 'Our secret product', 63 | ])->withFields([ 64 | [ 65 | 'name' => 'id', 66 | 'description' => null, 67 | 'type_def' => 'ID!' 68 | ], 69 | [ 70 | 'name' => 'name', 71 | 'description' => 'The name of product', 72 | 'type_def' => 'String!' 73 | ], 74 | [ 75 | 'name' => 'color', 76 | 'description' => null, 77 | 'type_def' => 'Color' 78 | ], 79 | ]); 80 | } 81 | 82 | public function test_does_not_sync_schema_again_if_nothing_has_changed() 83 | { 84 | $this->markTestIncomplete(); 85 | } 86 | 87 | public function test_update_schema_after_new_request_if_it_has_changed() 88 | { 89 | // TODO not working because cant change schema on same test method 90 | $this->markTestSkipped(); 91 | 92 | $this->schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 93 | 94 | $this->rebuildTestSchema(); 95 | 96 | $response = $this->graphQL(' 97 | { 98 | categories{ 99 | id 100 | name 101 | } 102 | } 103 | '); 104 | 105 | $this->assertEquals(Type::count(), 4); 106 | 107 | $this->assertHasType([ 108 | 'name' => 'Query', 109 | ])->withFields([ 110 | [ 111 | 'name' => 'products', 112 | 'description' => 'List all products', 113 | 'type_def' => '[Product]' 114 | ], 115 | [ 116 | 'name' => 'categories', 117 | 'description' => 'List all categories', 118 | 'type_def' => '[Category]' 119 | ] 120 | ]); 121 | 122 | $this->assertHasType([ 123 | 'name' => 'Category', 124 | 'description' => 'A category', 125 | ])->withFields([ 126 | [ 127 | 'name' => 'id', 128 | 'description' => null, 129 | 'type_def' => 'ID!' 130 | ], 131 | [ 132 | 'name' => 'name', 133 | 'description' => null, 134 | 'type_def' => 'String!' 135 | ], 136 | ]); 137 | 138 | $this->assertHasType([ 139 | 'name' => 'Product', 140 | 'description' => 'Our new secret product', 141 | ])->withFields([ 142 | [ 143 | 'name' => 'id', 144 | 'description' => null, 145 | 'type_def' => 'ID!' 146 | ], 147 | [ 148 | 'name' => 'name', 149 | 'description' => 'The greate name of product', 150 | 'type_def' => 'String!' 151 | ], 152 | [ 153 | 'name' => 'color', 154 | 'description' => null, 155 | 'type_def' => 'Color!' 156 | ], 157 | [ 158 | 'name' => 'category', 159 | 'description' => null, 160 | 'type_def' => 'Category!' 161 | ], 162 | ]); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/Feature/TypesTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 22 | 23 | ProductFactory::times(3)->create(); 24 | 25 | $this->clients = new Collection(); 26 | $this->clients->push(Client::first()); // anonymous 27 | 28 | $moreClients = ClientFactory::times(3)->create(); 29 | $moreClients->each(fn ($client) => $this->clients->push($client)); 30 | } 31 | 32 | /** 33 | * @dataProvider dataset 34 | */ 35 | public function test_types_with_filters($requests, $query_string, $expect) 36 | { 37 | foreach ($requests as $request) { 38 | $client = $this->clients->where('id', $request['client_id'])->first(); 39 | 40 | $this->customGraphQLRequest() 41 | ->withDateTime($request['dateTime']) 42 | ->forClient($client) 43 | ->times($request['times']) 44 | ->query($request['query']); 45 | } 46 | 47 | $total_types = Type::count(); 48 | 49 | $this->get("/lighthouse-dashboard/types?{$query_string}") 50 | ->assertPropCount("types", $total_types) 51 | ->assertPropValue("types", function ($types) use ($expect) { 52 | 53 | $types = collect($types)->pluck('fields', 'name')->map(function ($item) { 54 | return collect($item)->pluck('total_requests', 'name'); 55 | })->toArray(); 56 | 57 | $this->assertEqualsCanonicalizing($types, $expect['types']); 58 | }); 59 | } 60 | 61 | public function dataset() 62 | { 63 | return [ 64 | // CASE 1 65 | [ 66 | 'requests' => [ 67 | [ 68 | "dateTime" => "today", 69 | "client_id" => 1, // "1" is anonymous 70 | "times" => 3, 71 | "query" => '{ categories {id name} }' 72 | ], 73 | [ 74 | "dateTime" => "yesterday", 75 | "client_id" => 2, 76 | "times" => 4, 77 | "query" => '{ products {id name} }' 78 | ], 79 | ], 80 | 'query_string' => "start_date=yesterday", 81 | 'expect' => [ 82 | 'types' => [ 83 | 'Query' => [ 84 | 'products' => 4, 85 | 'categories' => 3 86 | ], 87 | 'Product' => [ 88 | 'id' => 4, 89 | 'name' => 4, 90 | 'color' => 0, 91 | 'category' => 0 92 | ], 93 | 'Color' => [ 94 | 'id' => 0, 95 | 'name' => 0 96 | ], 97 | 'Category' => [ 98 | 'id' => 3, 99 | 'name' => 3 100 | ] 101 | ] 102 | ] 103 | ], 104 | // CASE 2 105 | [ 106 | 'requests' => [ 107 | [ 108 | "dateTime" => "today", 109 | "client_id" => 1, // "1" is anonymous 110 | "times" => 3, 111 | "query" => '{ categories {id name} }' 112 | ], 113 | [ 114 | "dateTime" => "yesterday", 115 | "client_id" => 2, 116 | "times" => 4, 117 | "query" => '{ products {id name color { id name } } }' 118 | ], 119 | ], 120 | 'query_string' => "start_date=yesterday", 121 | 'expect' => [ 122 | 'types' => [ 123 | 'Query' => [ 124 | 'products' => 4, 125 | 'categories' => 3 126 | ], 127 | 'Product' => [ 128 | 'id' => 4, 129 | 'name' => 4, 130 | 'color' => 4, 131 | 'category' => 0 132 | ], 133 | 'Color' => [ 134 | 'id' => 4, 135 | 'name' => 4 136 | ], 137 | 'Category' => [ 138 | 'id' => 3, 139 | 'name' => 3 140 | ] 141 | ] 142 | ] 143 | ], 144 | 145 | ]; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/Feature/OperationsSlowTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 18 | 19 | $this->clients = new Collection(); 20 | $this->clients->push(Client::first()); // anonymous 21 | 22 | $moreClients = ClientFactory::times(3)->create(); 23 | $moreClients->each(fn ($client) => $this->clients->push($client)); 24 | } 25 | 26 | /** 27 | * @dataProvider dataset 28 | */ 29 | public function test_slow_with_filter($requests, $query_string, $expect) 30 | { 31 | foreach ($requests as $request) { 32 | $this->customGraphQLRequest() 33 | ->withDateTime($request['dateTime']) 34 | ->withDuration($request['duration']) 35 | ->times($request['times']) 36 | ->query($request['query']); 37 | } 38 | 39 | $this->get("/lighthouse-dashboard/operations?{$query_string}") 40 | ->assertPropCount("slowlestOperations", $expect['total_operations']) 41 | ->assertPropValue("slowlestOperations", function ($data) use ($expect) { 42 | foreach ($data as $key => $data) { 43 | $this->assertEquals($data['field']['name'], $expect['operations'][$key]['name']); 44 | $this->assertEquals($data['average_duration'], $expect['operations'][$key]['average_duration']); 45 | $this->assertEquals($data['latest_duration'], $expect['operations'][$key]['latest_duration']); 46 | } 47 | }); 48 | } 49 | 50 | public function dataset() 51 | { 52 | return [ 53 | // CASE 1 54 | [ 55 | 'requests' => [ 56 | [ 57 | "dateTime" => "yesterday", 58 | "times" => 2, 59 | "duration" => 2000000, 60 | "query" => '{ products {id name} }' 61 | ], 62 | [ 63 | "dateTime" => "today", 64 | "times" => 7, 65 | "duration" => 1000000, 66 | "query" => '{ products {id name} }' 67 | ], 68 | [ 69 | "dateTime" => "today", 70 | "times" => 7, 71 | "duration" => 1000000, 72 | "query" => '{ categories {id name} }' 73 | ], 74 | ], 75 | 'query_string' => "start_date=yesterday", 76 | 'expect' => [ 77 | 'total_operations' => 2, 78 | 'operations' => [ 79 | [ 80 | 'name' => 'products', 81 | 'average_duration' => 1222222, 82 | 'latest_duration' => 1000000 83 | ], 84 | [ 85 | 'name' => 'categories', 86 | 'average_duration' => 1000000, 87 | 'latest_duration' => 1000000 88 | ], 89 | ] 90 | ] 91 | ], 92 | // CASE 1 93 | [ 94 | 'requests' => [ 95 | [ 96 | "dateTime" => "yesterday", 97 | "times" => 2, 98 | "client_id" => 2, 99 | "duration" => 2000000, 100 | "query" => '{ products {id name} }' 101 | ], 102 | [ 103 | "dateTime" => "today", 104 | "times" => 7, 105 | "client_id" => 1, 106 | "duration" => 3000000, 107 | "query" => '{ products {id name} }' 108 | ], 109 | [ 110 | "dateTime" => "today", 111 | "times" => 7, 112 | "client_id" => 1, 113 | "duration" => 1000000, 114 | "query" => '{ categories {id name} }' 115 | ], 116 | ], 117 | 'query_string' => "start_date=today&clients[]=1", 118 | 'expect' => [ 119 | 'total_operations' => 2, 120 | 'operations' => [ 121 | [ 122 | 'name' => 'products', 123 | 'average_duration' => 3000000, 124 | 'latest_duration' => 3000000 125 | ], 126 | [ 127 | 'name' => 'categories', 128 | 'average_duration' => 1000000, 129 | 'latest_duration' => 1000000 130 | ], 131 | ] 132 | ] 133 | ], 134 | ]; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /resources/js/pages/Errors.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 179 | 188 | -------------------------------------------------------------------------------- /tests/Feature/OperationsTopTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 20 | 21 | $this->clients = new Collection(); 22 | $this->clients->push(Client::first()); // anonymous 23 | 24 | $moreClients = ClientFactory::times(3)->create(); 25 | $moreClients->each(fn ($client) => $this->clients->push($client)); 26 | } 27 | 28 | /** 29 | * @dataProvider dataset 30 | */ 31 | public function test_top_operations_with_filters($requests, $query_string, $expect) 32 | { 33 | foreach ($requests as $request) { 34 | $client = $this->clients->where('id', $request['client_id'])->first(); 35 | 36 | $this->customGraphQLRequest() 37 | ->withDateTime($request['dateTime']) 38 | ->forClient($client) 39 | ->times($request['times']) 40 | ->query($request['query']); 41 | } 42 | 43 | $this->get("/lighthouse-dashboard/operations?{$query_string}") 44 | ->assertPropCount("topOperations", $expect['total_operations']) 45 | ->assertPropValue("topOperations", function ($data) use ($expect) { 46 | foreach ($data as $key => $data) { 47 | $this->assertEquals($data['field']['name'], $expect['operations'][$key]['name']); 48 | $this->assertEquals($data['total_requests'], $expect['operations'][$key]['total_requests']); 49 | } 50 | }); 51 | } 52 | 53 | public function dataset() 54 | { 55 | return [ 56 | // CASE 1 57 | [ 58 | 'requests' => [ 59 | [ 60 | "dateTime" => "today", 61 | "client_id" => 1, // "1" is anonymous 62 | "times" => 3, 63 | "query" => '{ categories {id name} }' 64 | ], 65 | [ 66 | "dateTime" => "yesterday", 67 | "client_id" => 2, 68 | "times" => 4, 69 | "query" => '{ products {id name} }' 70 | ], 71 | ], 72 | 'query_string' => "start_date=yesterday", 73 | 'expect' => [ 74 | 'total_operations' => 2, 75 | 'operations' => [ 76 | [ 77 | 'name' => 'products', 78 | 'total_requests' => 4 79 | ], 80 | [ 81 | 'name' => 'categories', 82 | 'total_requests' => 3 83 | ], 84 | ] 85 | ] 86 | ], 87 | // CASE 2 88 | [ 89 | 'requests' => [ 90 | [ 91 | "dateTime" => "today", 92 | "client_id" => 1, // "1" is anonymous 93 | "times" => 3, 94 | "query" => '{ categories {id name} }' 95 | ], 96 | [ 97 | "dateTime" => "yesterday", 98 | "client_id" => 1, 99 | "times" => 4, 100 | "query" => '{ products {id name} }' 101 | ], 102 | ], 103 | 'query_string' => "start_date=today", 104 | 'expect' => [ 105 | 'total_operations' => 2, 106 | 'operations' => [ 107 | [ 108 | 'name' => 'categories', 109 | 'total_requests' => 3 110 | ], 111 | [ 112 | 'name' => 'products', 113 | 'total_requests' => 0 114 | ], 115 | ] 116 | ] 117 | ], 118 | // CASE 3 119 | [ 120 | 'requests' => [ 121 | [ 122 | "dateTime" => "today", 123 | "client_id" => 1, 124 | "times" => 3, 125 | "query" => '{ categories {id name} }' 126 | ], 127 | [ 128 | "dateTime" => "yesterday", 129 | "client_id" => 2, 130 | "times" => 4, 131 | "query" => '{ products {id name} }' 132 | ], 133 | [ 134 | "dateTime" => "-5 days", 135 | "client_id" => 1, 136 | "times" => 4, 137 | "query" => '{ products {id name} }' 138 | ], 139 | ], 140 | 'query_string' => "start_date=last week&clients[]=2", 141 | 'expect' => [ 142 | 'total_operations' => 2, 143 | 'operations' => [ 144 | [ 145 | 'name' => 'products', 146 | 'total_requests' => 4 147 | ], 148 | [ 149 | 'name' => 'categories', 150 | 'total_requests' => 0 151 | ], 152 | ] 153 | ] 154 | ] 155 | ]; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /resources/js/pages/Types.vue: -------------------------------------------------------------------------------- 1 | 99 | 100 | -------------------------------------------------------------------------------- /resources/js/pages/Operations.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | -------------------------------------------------------------------------------- /src/Actions/StoreMetrics.php: -------------------------------------------------------------------------------- 1 | client = $client; 42 | $this->schema = $schema; 43 | $this->payload = $payload; 44 | $this->result = $result; 45 | 46 | $this->tracing = $this->getTracing(); 47 | $this->errors = $this->getErrors(); 48 | $this->requested_at = now(); 49 | } 50 | 51 | public function handle() 52 | { 53 | try { 54 | DB::beginTransaction(); 55 | SyncGraphQLSchema::run($this->schema); 56 | $this->storeOperationMetrics(); 57 | DB::commit(); 58 | } catch (\Throwable $th) { 59 | throw $th; 60 | DB::rollBack(); 61 | } 62 | } 63 | 64 | private function storeOperationMetrics(): void 65 | { 66 | $field = $this->getFieldForOperation(); 67 | 68 | // If cant parse operation field just returns. Sorry :( 69 | if ($field == null) { 70 | return; 71 | } 72 | 73 | $this->operation = Operation::firstOrCreate([ 74 | 'field_id' => $field->id, 75 | ]); 76 | 77 | // If operation has errors, log the error then return. 78 | if (count($this->errors)) { 79 | $this->storeErrors(); 80 | return; 81 | } 82 | 83 | $this->storeFieldMetrics(); 84 | } 85 | 86 | private function storeFieldMetrics(): void 87 | { 88 | $this->getResolvers() 89 | ->each(function ($path) { 90 | $field = $this->getfieldByPath($path); 91 | 92 | $request = Request::create([ 93 | 'field_id' => $field->id, 94 | 'client_id' => $this->client->id, 95 | 'operation_id' => $this->operation->id, 96 | 'requested_at' => $this->requested_at 97 | ]); 98 | 99 | // Store tracing only if this field is a operation itself 100 | if ($field->is($this->operation->field)) { 101 | $request->update(['duration' => $this->tracing['duration']]); 102 | $this->storeTracing($request); 103 | } 104 | }); 105 | } 106 | 107 | private function storeTracing(Request $request): void 108 | { 109 | Tracing::create([ 110 | 'request_id' => $request->id, 111 | 'operation_id' => $this->operation->id, 112 | 'payload' => $this->payload, 113 | 'execution' => $this->tracing, 114 | 'start_time' => $this->tracing['startTime'], 115 | 'end_time' => $this->tracing['endTime'], 116 | 'duration' => $this->tracing['duration'], 117 | ]); 118 | } 119 | 120 | private function storeErrors() 121 | { 122 | $request = Request::create([ 123 | 'field_id' => $this->operation->field_id, 124 | 'client_id' => $this->client->id, 125 | 'operation_id' => $this->operation->id, 126 | 'requested_at' => $this->requested_at, 127 | 'duration' => $this->tracing['duration'] 128 | ]); 129 | 130 | /** 131 | * Prevent log same message multiples times. 132 | * Usually when error is on node with multiples items (hasMany). 133 | * So the error woul be the same for each path. 134 | */ 135 | $errors = collect($this->errors)->unique('message'); 136 | 137 | foreach ($errors as $error) { 138 | Error::create([ 139 | 'request_id' => $request->id, 140 | 'category' => $error->getCategory(), 141 | 'message' => $error->getMessage(), 142 | 'original_exception' => $error->getPrevious(), 143 | 'body' => $error 144 | ]); 145 | } 146 | } 147 | 148 | private function getResolvers(): Collection 149 | { 150 | return collect($this->tracing['execution']['resolvers']) 151 | ->map(fn ($item) => ['parentType' => $item['parentType'], 'fieldName' => $item['fieldName']]) 152 | ->unique(); 153 | } 154 | 155 | private function getfieldByPath(array $path) 156 | { 157 | $type = Type::where('name', $path['parentType'])->first(); 158 | 159 | return Field::query()->where(['type_id' => $type->id, 'name' => $path['fieldName']])->first(); 160 | } 161 | 162 | // TODO better away? Cant get operation name from execution context. 163 | private function getFieldForOperation(): ?Field 164 | { 165 | $path = $this->getOperationPathFromResolvers(); 166 | 167 | // Some errors does not include on response the path, so we need to infer the operation name 168 | if (!$path) { 169 | $path['fieldName'] = $this->inferOperationName(); 170 | $path['parentType'] = 'Query'; 171 | } 172 | 173 | return $this->getfieldByPath($path); 174 | } 175 | 176 | private function getOperationPathFromResolvers() 177 | { 178 | return isset($this->getResolvers()[0]) ? $this->getResolvers()[0] : null; 179 | } 180 | 181 | private function getTracing() 182 | { 183 | return $this->result->result->extensions['tracing']; 184 | } 185 | 186 | private function getErrors() 187 | { 188 | return $this->result->result->errors; 189 | } 190 | 191 | private function inferOperationName() 192 | { 193 | $regex = "/({)(\n*)(.*)(\n*)({)/"; 194 | $matches = []; 195 | 196 | preg_match($regex, $this->payload, $matches); 197 | 198 | $operationName = isset($matches[3]) ? trim($matches[3]) : null; 199 | 200 | // if has args, remove them and get operation name only 201 | if (strstr($operationName, '(')) { 202 | $operationName = explode('(', $operationName)[0]; 203 | } 204 | 205 | return $operationName; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /public/vendor/lighthouse-dashboard/js/7.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[7],{Hluy:function(t,e,s){"use strict";s.r(e);var a=s("o0o1"),i=s.n(a),r={props:["series"],data:function(){return{chart:{series:[{name:"Requests",data:this.series||[]}],options:{chart:{zoom:{enabled:!1},toolbar:{show:!1}},grid:{show:!1},dataLabels:{enabled:!1},stroke:{curve:"smooth"},xaxis:{type:"datetime"},yaxis:{show:!1},tooltip:{y:{formatter:function(t){return new Intl.NumberFormat("pt-BR").format(t)}}}}}}},watch:{series:function(t){this.chart.series=[{data:t}]}}},n=s("KHd+"),l=Object(n.a)(r,(function(){var t=this.$createElement;return(this._self._c||t)("apexchart",{attrs:{type:"area",height:"150",options:this.chart.options,series:this.chart.series}})}),[],!1,null,null,null).exports,o={props:["series"],data:function(){return{chart:{series:[{name:"Requests",data:this.series||[]}],options:{chart:{toolbar:{show:!1}},grid:{show:!1},plotOptions:{bar:{horizontal:!0}},dataLabels:{enabled:!1},tooltip:{y:{formatter:function(t){return new Intl.NumberFormat("pt-BR").format(t)}}}}}}},watch:{series:function(t){this.chart.series=[{name:"Requests",data:t}]}},computed:{height:function(){return this.series.length>2?40*this.series.length:140}}},c=Object(n.a)(o,(function(){var t=this.$createElement,e=this._self._c||t;return e("div",[e("apexchart",{attrs:{type:"bar",height:this.height,options:this.chart.options,series:this.chart.series}})],1)}),[],!1,null,null,null).exports,u=s("VDrJ"),v=s("LvDl"),h=s.n(v);function f(t,e,s,a,i,r,n){try{var l=t[r](n),o=l.value}catch(t){return void s(t)}l.done?e(o):Promise.resolve(o).then(a,i)}var d={props:["schema","requests_series","client_series","clients","start_date","range","selectedClients"],components:{RequestsChart:l,ClientsChart:c,Filters:u.a},data:function(){return{loading:!1,display:{filters:!1,sumary:!1},filters:{form:{start_date:this.start_date||"today",range:this.range||[],clients:this.selectedClients||[]},options:{clients:this.clients||[]}}}},computed:{total_requests:function(){return h.a.sumBy(this.requests_series,"y")},total_clients:function(){return this.client_series.length}},methods:{displayNavigation:function(){this.display.navigation=!0},hideNavigation:function(){this.display.navigation=!1},filter:function(){var t,e=this;return(t=i.a.mark((function t(){return i.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return e.loading=!0,t.next=3,e.$inertia.replace("/lighthouse-dashboard",{data:e.filters.form,replace:!0,preserveScroll:!0});case 3:e.loading=!1;case 4:case"end":return t.stop()}}),t)})),function(){var e=this,s=arguments;return new Promise((function(a,i){var r=t.apply(e,s);function n(t){f(r,a,i,n,l,"next",t)}function l(t){f(r,a,i,n,l,"throw",t)}n(void 0)}))})()}}},m=Object(n.a)(d,(function(){var t=this,e=t.$createElement,s=t._self._c||e;return s("div",[s("v-app-bar",{staticClass:"pt-2",attrs:{app:"",color:"white",elevation:"1"}},[s("v-row",{staticClass:"mb-5",attrs:{align:"center"}},[s("v-col",[s("h2",[s("v-icon",{staticClass:"mb-1",attrs:{color:"black",left:""}},[t._v("mdi-graphql")]),t._v("Schema\n ")],1)]),t._v(" "),s("v-col",{staticClass:"text-right primary--text",attrs:{cols:"auto"}},[s("v-icon",{staticClass:"mb-1 primary--text"},[t._v("mdi-clock-outline")]),t._v("\n "+t._s(t.filters.form.start_date)+"\n "),s("v-btn",{staticClass:"ml-3",attrs:{color:"primary",fab:"","x-small":"",depressed:"",dark:""},on:{click:function(e){return t.displayNavigation()}}},[s("v-icon",[t._v("mdi-filter-variant")])],1)],1)],1)],1),t._v(" "),0===t.requests_series.length?s("div",{staticClass:"text-center grey--text"},[s("v-icon",{attrs:{color:"grey","x-large":""}},[t._v("mdi-weather-windy")]),t._v(" "),s("h3",{staticClass:"mt-3"},[t._v("Oops! Nothing here.")]),t._v(" "),s("p",{staticClass:"text-caption mt-3"},[t._v("Make your first request to this Schema.")])],1):t._e(),t._v(" "),t.requests_series.length?s("div",[s("div",{staticClass:"title"},[t._v("Requests")]),t._v(" "),s("div",{staticClass:"text-caption grey--text mb-3"},[t._v("\n "+t._s(t._f("numeral")(t.total_requests,0))+" requests in selected period.\n ")]),t._v(" "),s("v-card",[s("v-card-text",[s("requests-chart",{attrs:{series:t.requests_series}})],1)],1)],1):t._e(),t._v(" "),t.requests_series.length?s("div",[s("div",{staticClass:"title mt-8"},[t._v("Clients")]),t._v(" "),s("div",{staticClass:"text-caption grey--text mb-3"},[t._v("\n "+t._s(t._f("numeral")(t.total_clients,0))+" clients in selected period.\n ")]),t._v(" "),s("v-card",[s("v-card-text",[s("clients-chart",{attrs:{series:t.client_series}})],1)],1)],1):t._e(),t._v(" "),s("v-navigation-drawer",{staticClass:"pa-5",attrs:{app:"",stateless:"",right:"",width:"380"},model:{value:t.display.navigation,callback:function(e){t.$set(t.display,"navigation",e)},expression:"display.navigation"}},[s("filters",{attrs:{filters:t.filters},on:{filter:function(e){return t.filter()},close:function(e){return t.hideNavigation()}}})],1),t._v(" "),s("v-overlay",{attrs:{value:t.loading}},[s("v-progress-circular",{attrs:{indeterminate:""}})],1)],1)}),[],!1,null,null,null);e.default=m.exports},VDrJ:function(t,e,s){"use strict";var a={props:["filters"],data:function(){return{options:[{value:"today",label:"Today"},{value:"yesterday",label:"Yesterday"},{value:"last week",label:"Last week"},{value:"last month",label:"Last Month"},{value:"in custom range",label:"In custom range"}]}},computed:{dateRangeText:function(){return this.filters.form.range.join(" ~ ")},isCustomRange:function(){return"in custom range"===this.filters.form.start_date}},methods:{filter:function(){this.isCustomRange&&this.filters.form.range.length<2||(this.isCustomRange||(this.filters.form.range=[]),this.$emit("filter"))},uncheckAll:function(){this.filters.form.clients.length&&(this.filters.form.clients=[]),this.filter()}}},i=s("KHd+"),r=Object(i.a)(a,(function(){var t=this,e=t.$createElement,s=t._self._c||e;return s("v-card",{attrs:{flat:""}},[s("v-card-title",[s("h3",[t._v("Filters")]),t._v(" "),s("v-spacer"),t._v(" "),s("v-btn",{attrs:{icon:""},on:{click:function(e){return t.$emit("close")}}},[s("v-icon",[t._v("mdi-close")])],1)],1),t._v(" "),s("v-card-text",{staticClass:"mt-5"},[s("div",{staticClass:"font-weight-black text-caption"},[t._v("STARTING FROM")]),t._v(" "),s("v-radio-group",{on:{change:function(e){return t.filter()}},model:{value:t.filters.form.start_date,callback:function(e){t.$set(t.filters.form,"start_date",e)},expression:"filters.form.start_date"}},t._l(t.options,(function(t){return s("v-radio",{key:t.value,attrs:{label:t.label,value:t.value}})})),1),t._v(" "),t.isCustomRange?s("div",[s("v-date-picker",{staticClass:"elevation-2",attrs:{max:(new Date).toISOString(),"show-current":!1,"no-title":"",range:""},on:{change:function(e){return t.filter()}},model:{value:t.filters.form.range,callback:function(e){t.$set(t.filters.form,"range",e)},expression:"filters.form.range"}}),t._v(" "),s("div",{staticClass:"py-3 font-weight-bold"},[t.dateRangeText?s("v-icon",{staticClass:"mb-1",attrs:{small:"",left:""}},[t._v("mdi-selection-drag")]):t._e(),t._v("\n "+t._s(t.dateRangeText)+"\n ")],1)],1):t._e(),t._v(" "),s("div",{staticClass:"font-weight-black text-caption mt-5 mb-5"},[t._v("CLIENTS")]),t._v(" "),s("v-btn",{attrs:{"x-small":"",outlined:""},on:{click:function(e){return t.uncheckAll()}}},[t._v("uncheck all")]),t._v(" "),t._l(t.filters.options.clients,(function(e){return s("v-checkbox",{key:e.id,attrs:{label:e.username,value:e.id,"hide-details":"",multiple:""},on:{change:function(e){return t.filter()}},model:{value:t.filters.form.clients,callback:function(e){t.$set(t.filters.form,"clients",e)},expression:"filters.form.clients"}})}))],2)],1)}),[],!1,null,null,null);e.a=r.exports}}]); 2 | //# sourceMappingURL=7.js.map?id=151ef4e05856dcb9a9fc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | # Dashboard for Laravel Lighthouse GraphQL 14 | 15 | **:warning: WORK IN PROGRESS! BREAKING CHANGES ARE EXPECTED!** 16 | 17 | This package adds a standalone analytics dasbhoard with metrics collected from [Laravel Lighthouse GraphQL Server](https://lighthouse-php.com/). 18 | 19 | Requirements: 20 | 21 | - PHP >= 7.4 22 | - Laravel >= 8.x 23 | - Laravel Lighthouse >= 5.x 24 | 25 | Questions? Join us in [Slack Channel](https://join.slack.com/t/lighthousedashboard/shared_invite/zt-hyqiy6fk-HHrxTH_nJH7VtfKfuCSv5Q). 26 | 27 |
28 | 29 | 30 | 31 |

32 | 33 | 34 | 35 |

36 | 37 | 38 | 39 |

40 | 41 | 42 | 43 |

44 | 45 | 46 | 47 | 48 | # Install 49 | 50 | Require the package. 51 | 52 | ``` 53 | composer require robsontenorio/lighthouse-dashboard 54 | ``` 55 | 56 | 57 | Publish package assets and config file. 58 | 59 | ``` 60 | php artisan lighthouse-dashboard:publish 61 | ``` 62 | 63 | Configure the package. 64 | 65 | ```php 66 | // config/lighthouse-dashboard.php 67 | 68 | return [ 69 | /** 70 | * Authenticated user attribute for identify the current client. 71 | * 72 | * If there is no authenticated user a `anonymous` will be used. 73 | * Default is `Auth::user()->username` 74 | */ 75 | 76 | 'client_identifier' => 'username', 77 | 78 | /** 79 | * Database connection name for the dashboard. 80 | * 81 | * By default it uses different connection. You must create it. 82 | * Or set it to `null` if want to use same connection from target app. 83 | */ 84 | 85 | 'connection' => 'dashboard', 86 | ]; 87 | ``` 88 | 89 | Run package migrations. 90 | 91 | ``` 92 | php artisan lighthouse-dashboard:migrate 93 | ``` 94 | 95 | Open the dashboard. 96 | 97 | ``` 98 | http://your-app/lighthouse-dashboard 99 | ``` 100 | 101 | To keep the assets up-to-date and avoid issues in future updates, we highly recommend adding the command to the post-autoload-dump section in your `composer.json` file: 102 | 103 | ```json 104 | { 105 | "scripts": { 106 | "post-autoload-dump": [ 107 | "@php artisan lighthouse-dashboard:publish-assets" 108 | ] 109 | } 110 | } 111 | ``` 112 | 113 | ### Note about phpunit tests 114 | 115 | This dashboard collects metrics by listening `Nuwave\Lighthouse\Events\ManipulateResult` . Make sure to disable this on your parent `TestCase`, in order to prevent metrics collecting while testing your app. 116 | 117 | ```php 118 | use Nuwave\Lighthouse\Events\ManipulateResult; 119 | 120 | abstract class TestCase extends BaseTestCase 121 | { 122 | // use this Trait 123 | use DisableDashboardMetrics; 124 | 125 | public function setUp(): void 126 | { 127 | parent::setUp(); 128 | 129 | // Then, disable metrics while testing 130 | $this->withoutDashboardMetrics(); 131 | } 132 | } 133 | ``` 134 | 135 | # How does it works? 136 | 137 |
138 | See more ...

139 | 140 | This package enables built-in `Tracing` extension from Laravel Lighthouse GraphQL Server. So, every operation automatically is profiled with its execution metrics. 141 | 142 | - GraphQL request is made. 143 | - Dashboard listen to `ManipulateResult` event and collect metrics from current operation. 144 | - Metrics are stored on dashboard. 145 | 146 | The GraphQL server performance is not affected by this package, once metrics are collect after response is sent by server. You can also disable tracing output from server response. See "Configurations" section. 147 |
148 | 149 | # Configurations 150 | 151 |
152 | See more ...

153 | 154 | /config/lighthouse-dashboard.php 155 | ```php 156 | return [ 157 | /** 158 | * Authenticated user attribute for identify the current client. 159 | * 160 | * If there is no authenticated user a `anonymous` will be used. 161 | * Default is `Auth::user()->username` 162 | */ 163 | 164 | 'client_identifier' => 'username', 165 | 166 | /** 167 | * Database connection name for the dashboard. 168 | * 169 | * By default it uses different connection. You must create it. 170 | * Or set it to `null` if want to use same connection from target app. 171 | */ 172 | 173 | 'connection' => 'dashboard', 174 | 175 | /** 176 | * Silent tracing. 177 | * 178 | * This package auto-register TracingServiceProvider from "nuwave/lighthouse". 179 | * This is a required feature to make this package working. 180 | * 181 | * If you want including tracing output on server response just set it to `false`. 182 | * 183 | */ 184 | 185 | 'silent_tracing' => true, 186 | 187 | /** 188 | * Ignore clients. 189 | * 190 | * Ignore all request from these clients based on `client_identifier`. 191 | * 192 | */ 193 | 194 | 'ignore_clients' => [] 195 | ]; 196 | ``` 197 |
198 | 199 | # Tests 200 | 201 |
202 | See more ...

203 | 204 | ```bash 205 | # run once 206 | composer test 207 | 208 | # run in watch mode 209 | composer test:watch 210 | 211 | # run once with coverage report in terminal 212 | # see full report in ./coverage/html/index.html 213 | composer test:coverage 214 | ``` 215 | 216 | If you need to tweak UI see "Local development" section. 217 |
218 | 219 | # Local development 220 | 221 |
See more ...

222 | 223 | Once this package includes UI, the only way to see it is by running it through target app. 224 | 225 | ### Uninstall 226 | 227 | If you previous installed this package, **first uninstall it from target app**. 228 | 229 | Remove this entry from `composer.json`. 230 | 231 | ```json 232 | { 233 | "scripts": { 234 | "post-autoload-dump": [ 235 | "@php artisan lighthouse-dashboard:publish-assets" 236 | ] 237 | } 238 | } 239 | ``` 240 | 241 | Remove package. 242 | 243 | ``` 244 | composer remove robsontenorio/lighthouse-dashboard 245 | ``` 246 | 247 | Remove package public assets from target app. 248 | 249 | ``` 250 | rm -rf /path/to/app/public/vendor/lighthouse-dashboard 251 | ``` 252 | 253 | ### Install locally 254 | 255 | Clone the repository, then on target app add to `composer.json` 256 | 257 | ```json 258 | "repositories": { 259 | "robsontenorio/lighthouse-dashboard": { 260 | "type": "path", 261 | "url": "/local/path/to/lighthouse-dashboard", 262 | "options": { 263 | "symlink": true 264 | } 265 | } 266 | } 267 | ``` 268 | 269 | Require local package version. 270 | 271 | ```sh 272 | composer require robsontenorio/lighthouse-dashboard @dev 273 | ``` 274 | 275 | Then, create a symlink from package vendor folder to app public assets folder. 276 | 277 | ```sh 278 | ln -s /path/to/app/vendor/robsontenorio/lighthouse-dashboard/public/vendor/lighthouse-dashboard /path/to/app/public/vendor 279 | ``` 280 | 281 | From target app enter to package vendor folder. 282 | 283 | ```sh 284 | cd vendor/robsontenorio/lighthouse-dashboard 285 | ``` 286 | 287 | Install frontend dependencies and start it on dev mode. 288 | 289 | ```sh 290 | yarn dev 291 | ``` 292 | 293 | Now all assets built inside package vendor folder will be symlinked to target app public vendor folder. 294 | 295 | Then point to http://localhost:3000/lighthouse-dashboard/ 296 | 297 | 298 | ## Reference model 299 | 300 | 301 | 302 |
303 | 304 | # Roadmap 305 | 306 | - [ ] Sumary for operations per clients. 307 | - [ ] UI navigation with anchor href when clicks on type return. 308 | - [ ] Add option to guard dashboard. 309 | - [ ] Add option for retention period. 310 | 311 | # Credits 312 | 313 | Developed by [Robson Tenório](https://twitter.com/robsontenorio) and [contributors](https://github.com/robsontenorio/lighthouse-dashboard/graphs/contributors). 314 | 315 | This work is highly inspired on [Apollo Studio](https://studio.apollographql.com/) and powered by: 316 | 317 | - Laravel. 318 | - Lighthouse GraphQL. 319 | - InertiaJS. 320 | - Vuetify. 321 | -------------------------------------------------------------------------------- /tests/Feature/OperationsWithDateFilterTest.php: -------------------------------------------------------------------------------- 1 | schema = File::get(__DIR__ . '/../Utils/Schemas/schema-full.graphql'); 16 | } 17 | 18 | public function test_today() 19 | { 20 | // Must ignore these 21 | $this->customGraphQLRequest() 22 | ->withDateTime("- 10 days") 23 | ->times(2) 24 | ->query(' 25 | { 26 | products{ 27 | id 28 | name 29 | } 30 | } 31 | '); 32 | 33 | // Must ignore these 34 | $this->customGraphQLRequest() 35 | ->withDateTime("yesterday") 36 | ->times(5) 37 | ->query(' 38 | { 39 | products{ 40 | id 41 | name 42 | } 43 | } 44 | '); 45 | 46 | // Must get these. It is "now". 47 | $this->customGraphQLRequest() 48 | ->times(3) 49 | ->query(' 50 | { 51 | products{ 52 | id 53 | name 54 | } 55 | } 56 | '); 57 | 58 | $this->get("/lighthouse-dashboard/operations?start_date=today") 59 | ->assertPropCount("topOperations", 1) 60 | ->assertPropValue("topOperations", function ($data) { 61 | $this->assertEquals($data[0]['total_requests'], 3); 62 | $this->assertEquals($data[0]['field']['name'], 'products'); 63 | }); 64 | } 65 | 66 | public function test_yesterday() 67 | { 68 | // Must ignore these 69 | $this->customGraphQLRequest() 70 | ->withDateTime("-10 days") 71 | ->times(2) 72 | ->query(' 73 | { 74 | products{ 75 | id 76 | name 77 | } 78 | } 79 | '); 80 | 81 | // Must get these. Because it is "yesterday". 82 | $this->customGraphQLRequest() 83 | ->withDateTime("yesterday") 84 | ->times(5) 85 | ->query(' 86 | { 87 | products{ 88 | id 89 | name 90 | } 91 | } 92 | '); 93 | 94 | // Must get these. Because it is "today". 95 | $this->customGraphQLRequest() 96 | ->times(3) 97 | ->query(' 98 | { 99 | products{ 100 | id 101 | name 102 | } 103 | } 104 | '); 105 | 106 | $this->get("/lighthouse-dashboard/operations?start_date=yesterday") 107 | ->assertPropCount("topOperations", 1) 108 | ->assertPropValue("topOperations", function ($data) { 109 | $this->assertEquals($data[0]['total_requests'], 8); 110 | $this->assertEquals($data[0]['field']['name'], 'products'); 111 | }); 112 | } 113 | 114 | public function test_last_week() 115 | { 116 | // Must ignore these. 117 | $this->customGraphQLRequest() 118 | ->withDateTime("-20 days") 119 | ->times(2) 120 | ->query(' 121 | { 122 | products{ 123 | id 124 | name 125 | } 126 | } 127 | '); 128 | 129 | // Must get these. Because it is "last week" range. 130 | $this->customGraphQLRequest() 131 | ->withDateTime("-6 days") 132 | ->times(4) 133 | ->query(' 134 | { 135 | products{ 136 | id 137 | name 138 | } 139 | } 140 | '); 141 | 142 | // Must get these. Because it is "last week" range. 143 | $this->customGraphQLRequest() 144 | ->withDateTime("-7 days") 145 | ->times(3) 146 | ->query(' 147 | { 148 | products{ 149 | id 150 | name 151 | } 152 | } 153 | '); 154 | 155 | // Must get these. Because it is "today". 156 | $this->customGraphQLRequest() 157 | ->times(6) 158 | ->query(' 159 | { 160 | products{ 161 | id 162 | name 163 | } 164 | } 165 | '); 166 | 167 | $this->get("/lighthouse-dashboard/operations?start_date=last week") 168 | ->assertPropCount("topOperations", 1) 169 | ->assertPropValue("topOperations", function ($data) { 170 | $this->assertEquals($data[0]['total_requests'], 13); 171 | $this->assertEquals($data[0]['field']['name'], 'products'); 172 | }); 173 | } 174 | 175 | public function test_default_start_date_is_past_month() 176 | { 177 | // Must ignore these. 178 | $this->customGraphQLRequest() 179 | ->withDateTime("-60 days") 180 | ->times(3) 181 | ->query(' 182 | { 183 | products{ 184 | id 185 | name 186 | } 187 | } 188 | '); 189 | 190 | // Must get these. Because in range of "last month" 191 | $this->customGraphQLRequest() 192 | ->withDateTime("-30 days") 193 | ->times(3) 194 | ->query(' 195 | { 196 | products{ 197 | id 198 | name 199 | } 200 | } 201 | '); 202 | 203 | $this->get("/lighthouse-dashboard/operations") 204 | ->assertPropCount("topOperations", 1) 205 | ->assertPropValue("topOperations", function ($data) { 206 | $this->assertEquals($data[0]['total_requests'], 3); 207 | $this->assertEquals($data[0]['field']['name'], 'products'); 208 | }); 209 | } 210 | 211 | public function test_handle_inverted_custom_range_dates() 212 | { 213 | $this->customGraphQLRequest() 214 | ->withDateTime("-10 days") 215 | ->times(3) 216 | ->query(' 217 | { 218 | products{ 219 | id 220 | name 221 | } 222 | } 223 | '); 224 | 225 | // Simulating "start date" is great then "end date" 226 | $start = Carbon::parse("- 3 days")->toDateString(); 227 | $end = Carbon::parse("- 20 days")->toDateString(); 228 | 229 | // It is enough smart and fix dates, if inverted, to get metrics in right range 230 | $this->get("/lighthouse-dashboard/operations?start_date=in custom range&range[]={$start}&range[]={$end}") 231 | ->assertPropCount("topOperations", 1) 232 | ->assertPropValue("topOperations", function ($data) { 233 | $this->assertEquals($data[0]['total_requests'], 3); 234 | $this->assertEquals($data[0]['field']['name'], 'products'); 235 | }); 236 | } 237 | 238 | public function test_empty_custom_range_date_defaults_to_last_month() 239 | { 240 | // Must ignore this. 241 | $this->customGraphQLRequest() 242 | ->withDateTime("-60 days") 243 | ->times(4) 244 | ->query(' 245 | { 246 | products{ 247 | id 248 | name 249 | } 250 | } 251 | '); 252 | 253 | // Must get this. 254 | $this->customGraphQLRequest() 255 | ->withDateTime("-10 days") 256 | ->times(3) 257 | ->query(' 258 | { 259 | products{ 260 | id 261 | name 262 | } 263 | } 264 | '); 265 | 266 | // When custom range does not contain dates, defaults to last month. 267 | $this->get("/lighthouse-dashboard/operations?start_date=in custom range") 268 | ->assertPropCount("topOperations", 1) 269 | ->assertPropValue("topOperations", function ($data) { 270 | $this->assertEquals($data[0]['total_requests'], 3); 271 | $this->assertEquals($data[0]['field']['name'], 'products'); 272 | }); 273 | } 274 | } 275 | --------------------------------------------------------------------------------