Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ return [
TiMacDonald\Pulse\Recorders\ValidationErrors::class => [
'enabled' => env('PULSE_VALIDATION_ERRORS_ENABLED', true),
'sample_rate' => env('PULSE_VALIDATION_ERRORS_SAMPLE_RATE', 1),
'capture_messages' => true,
'ignore' => [
// '#^/login$#',
// '#^/register$#',
Expand Down
Binary file removed art/screenshot.jpg
Binary file not shown.
Binary file added art/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion dist/validation.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<php>
<env name="APP_KEY" value="base64:vFI2dJxJeGFdbe7JXMJkE6iIv0dKBCNBw/9hZJVF/UM="/>
<env name="DB_CONNECTION" value="testing"/>
<env name="PULSE_CACHE_DRIVER" value="array"/>
</php>
</phpunit>

56 changes: 21 additions & 35 deletions resources/views/validation-errors.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,61 +12,47 @@
</x-pulse::card-header>

<x-pulse::scroll :expand="$expand" wire:poll.5s="">
@if ($validationErrors->isEmpty())
@if ($errors->isEmpty())
<x-pulse::no-results />
@else
<x-pulse::table>
<x-pulse::thead>
<tr>
<x-pulse::th>Input</x-pulse::th>
<x-pulse::th>Via</x-pulse::th>
<x-pulse::th>Error</x-pulse::th>
<x-pulse::th class="text-right">Count</x-pulse::th>
</tr>
</x-pulse::thead>
<tbody>
@foreach ($validationErrors->take(100) as $validationError)
<tr class="h-2 first:h-0"></tr>
<tr wire:key="{{ $validationError->method.$validationError->uri.$validationError->name.$this->period }}">
<x-pulse::td class="overflow-hidden max-w-32 md:max-w-64">
<div class="break-words" title="{{ $validationError->name }}">
{{ $validationError->name }}
@foreach ($errors->take(100) as $error)
<tr wire:key="{{ $error->key_hash }}-spacer" class="h-2 first:h-0"></tr>
<tr wire:key="{{ $error->key_hash }}">
<x-pulse::td class="overflow-hidden max-w-[1px] space-y-2">
<div class="truncate" title="[{{ $error->name }}{{ $error->bag ? '@'.$error->bag : '' }}] {{ $error->message }}">
<span class="font-mono">[{{ $error->name }}{{ $error->bag ? '@'.$error->bag : '' }}]</span> <span class="text-gray-500 dark:text-gray-400">{{ $error->message }}</span>
</div>
@if ($validationError->bag !== 'default')
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 break-words" title="Error bag: {{ $validationError->bag }}">
{{ '@'.$validationError->bag }}
</div>
@endif
</x-pulse::td>
<x-pulse::td class="overflow-hidden max-w-[1px]">
<div class="flex flex-col">
<div class="mt-2">
<div class="flex gap-2">
<x-pulse::http-method-badge :method="$validationError->method" />
<code class="block text-xs text-gray-900 dark:text-gray-100 truncate" title="{{ $validationError->uri }}">
{{ $validationError->uri }}
</code>
</div>
</div>
@if ($validationError->action)
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 truncate" table="{{ $validationError->action }}">
{{ $validationError->action }}
</p>
@endif
<div class="flex gap-2">
<x-pulse::http-method-badge :method="$error->method" />
<code class="block text-xs text-gray-900 dark:text-gray-100 truncate" title="{{ $error->uri }}">
{{ $error->uri }}
</code>
</div>
</x-pulse>
<x-pulse::td numeric class="text-gray-700 dark:text-gray-300 font-bold">
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="{{ $error->action }}">
{{ $error->action }}
</p>
</x-pulse::td>
<x-pulse::td numeric class="text-gray-700 dark:text-gray-300 font-bold w-32">
@if ($config['sample_rate'] < 1)
<span title="Sample rate: {{ $config['sample_rate'] }}, Raw value: {{ number_format($validationError->count) }}">~{{ number_format($validationError->count * (1 / $config['sample_rate'])) }}</span>
<span title="Sample rate: {{ $config['sample_rate'] }}, Raw value: {{ number_format($error->count) }}">~{{ number_format($error->count * (1 / $config['sample_rate'])) }}</span>
@else
{{ number_format($validationError->count) }}
{{ number_format($error->count) }}
@endif
</x-pulse::td>
</tr>
@endforeach
</tbody>
</x-pulse::table>

@if ($validationErrors->count() > 100)
@if ($errors->count() > 100)
<div class="mt-2 text-xs text-gray-400 text-center">Limited to 100 entries</div>
@endif
@endif
Expand Down
114 changes: 114 additions & 0 deletions seed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Laravel\Pulse\Facades\Pulse;

Artisan::command('seed', function () {
$count = 841;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'POST',
'/user',
'App\Http\Controllers\UserController@store',
'default',
'password',
'The password field must be at least 8 characters.'
], flags: JSON_THROW_ON_ERROR),
)->count();
}
$count = 622;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'PATCH',
'/episodes',
'App\Http\Controllers\EpisodeController@update',
'default',
'version',
'The version field is required.'
], flags: JSON_THROW_ON_ERROR),
)->count();
}

$count = 588;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'POST',
'/shows',
'App\Http\Controllers\ShowController@store',
'default',
'website',
'The website field must start with one of the following: http://, https://.'
], flags: JSON_THROW_ON_ERROR),
)->count();
}

$count = 582;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'POST',
'/episodes',
'App\Http\Controllers\EpisodeController@store',
'default',
'version',
'The version field is required.'
], flags: JSON_THROW_ON_ERROR),
)->count();
}

$count = 288;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'DELETE',
'/episodes',
'App\Http\Controllers\EpisodeController@destroy',
'default',
'title_confirmation',
'The title confirmation does not match the episode title.',
], flags: JSON_THROW_ON_ERROR),
)->count();
}

$count = 205;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'POST',
'/episodes',
'App\Http\Controllers\EpisodeController@store',
'default',
'custom_feed_url',
'The custom feed url field must be a valid URL.',
], flags: JSON_THROW_ON_ERROR),
)->count();
}

$count = 107;
for ($i = 0; $i < $count; $i++) {
Pulse::record(
type: 'validation_error',
key: json_encode([
'PATCH',
'/users',
'App\Http\Controllers\UserController@update',
'default',
'password',
'The given password has appeared in a data leak. Please choose a different password.',
], flags: JSON_THROW_ON_ERROR),
)->count();
}

Pulse::ingest();
});

21 changes: 16 additions & 5 deletions src/Cards/ValidationErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,41 @@ class ValidationErrors extends Card
*/
public function render(): Renderable
{
[$validationErrors, $time, $runAt] = $this->remember(
[$errors, $time, $runAt] = $this->remember(
fn () => Pulse::aggregate(
'validation_error',
['count'],
$this->periodAsInterval(),
)->map(function ($row) {
[$method, $uri, $action, $bag, $name] = json_decode($row->key, flags: JSON_THROW_ON_ERROR);
[$method, $uri, $action, $bag, $name, $message] = json_decode($row->key, flags: JSON_THROW_ON_ERROR) + [5 => null];

return (object) [
'bag' => $bag,
'bag' => match ($bag) {
'default' => null,
default => $bag,
},
'uri' => $uri,
'name' => $name,
'action' => $action,
'method' => $method,
'message' => $message,
'count' => $row->count,
'key_hash' => md5($row->key),
];
}),
);

return View::make('timacdonald::validation-errors', [
'time' => $time,
'runAt' => $runAt,
'validationErrors' => $validationErrors,
'config' => Config::get('pulse.recorders.'.ValidationErrorsRecorder::class),
'errors' => $errors,
'config' => [
'enabled' => true,
'sample_rate' => 1,
'capture_messages' => true,
'ignore' => [],
...Config::get('pulse.recorders.'.ValidationErrorsRecorder::class, []),
],
]);
}

Expand Down
48 changes: 18 additions & 30 deletions src/Recorders/ValidationErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ protected function parseValidationErrors(Request $request, SymfonyResponse $resp
{
return $this->parseSessionValidationErrors($request, $response)
?? $this->parseJsonValidationErrors($request, $response)
?? $this->parseInertiaValidationErrors($request, $response)
?? $this->parseUnknownValidationErrors($request, $response)
?? collect([]);
}
Expand All @@ -98,6 +97,14 @@ protected function parseSessionValidationErrors(Request $request, SymfonyRespons
return null;
}

if ($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true)) {
return collect($errors->getBags())
->flatMap(fn ($bag, $bagName) => collect($bag->messages())
->flatMap(fn ($messages, $inputName) => array_map(
fn ($message) => [$bagName, $inputName, $message], $messages)
));
}

return collect($errors->getBags())->flatMap(
fn ($bag, $bagName) => array_map(fn ($inputName) => [$bagName, $inputName], $bag->keys())
);
Expand All @@ -121,36 +128,13 @@ protected function parseJsonValidationErrors(Request $request, SymfonyResponse $
return null;
}

return collect($errors)->keys()->map(fn ($inputName) => ['default', $inputName]);
}

/**
* Parse Inertia validation errors.
*
* @return null|\Illuminate\Support\Collection<int, array{ 0: string, 1: string }>
*/
protected function parseInertiaValidationErrors(Request $request, SymfonyResponse $response): ?Collection
{
if (
$request->isMethodSafe() ||
! $request->header('X-Inertia') ||
! $response instanceof JsonResponse ||
! is_array($response->original) ||
! array_key_exists('props', $response->original) ||
! is_array($response->original['props']) ||
! array_key_exists('errors', $response->original['props']) ||
! ($errors = $response->original['props']['errors']) instanceof stdClass
) {
return null;
if ($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true)) {
return collect($errors)->flatMap(fn ($messages, $inputName) => array_map(
fn ($message) => ['default', $inputName, $message], $messages)
);
}

if (is_string(($errors = collect($errors))->first())) {
return $errors->keys()->map(fn ($inputName) => ['default', $inputName]);
}

return $errors->flatMap(
fn ($bag, $bagName) => collect($bag)->keys()->map(fn ($inputName) => [$bagName, $inputName])
);
return collect($errors)->keys()->map(fn ($inputName) => ['default', $inputName]);
}

/**
Expand All @@ -164,6 +148,10 @@ protected function parseUnknownValidationErrors(Request $request, SymfonyRespons
return null;
}

return collect([['default', '__laravel_unknown']]);
return collect([[
'default',
'__laravel_unknown',
...($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true) ? ['__laravel_unknown'] : [])
]]);
}
}
Loading