diff --git a/composer.json b/composer.json index 7bb6acf6..52772f13 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "orchestra/testbench": "^8.0", "pestphp/pest": "^2.0", "phpstan/phpstan": "^1.10", - "predis/predis": "^2.1" + "predis/predis": "^2.2" }, "autoload": { "psr-4": { diff --git a/config/pulse.php b/config/pulse.php index 10d0d201..72e839d5 100644 --- a/config/pulse.php +++ b/config/pulse.php @@ -18,6 +18,9 @@ // in milliseconds 'slow_query_threshold' => 1000, + // in milliseconds + 'slow_job_threshold' => 1000, + // queues to show stats for 'queues' => [ 'default', diff --git a/database/migrations/2023_06_07_000001_create_pulse_tables.php b/database/migrations/2023_06_07_000001_create_pulse_tables.php new file mode 100644 index 00000000..89207c4a --- /dev/null +++ b/database/migrations/2023_06_07_000001_create_pulse_tables.php @@ -0,0 +1,83 @@ +timestamp('date'); + $table->string('server'); + $table->unsignedTinyInteger('cpu_percent'); + $table->unsignedInteger('memory_used'); + $table->unsignedInteger('memory_total'); + $table->json('storage'); + + $table->index(['server', 'date']); + }); + + Schema::create('pulse_requests', function (Blueprint $table) { + $table->timestamp('date'); + $table->string('user_id')->nullable(); + $table->string('route'); + $table->unsignedInteger('duration'); + + $table->index(['date', 'user_id'], 'user_usage'); + $table->index(['date', 'route', 'duration'], 'slow_endpoints'); + }); + + Schema::create('pulse_exceptions', function (Blueprint $table) { + $table->timestamp('date'); + $table->string('user_id')->nullable(); + $table->string('class'); + $table->string('location'); + + $table->index(['date', 'class', 'location']); + }); + + Schema::create('pulse_queries', function (Blueprint $table) { + $table->timestamp('date'); + $table->string('user_id')->nullable(); + $table->string('sql'); + $table->unsignedInteger('duration'); + + $table->index(['date', 'sql', 'duration'], 'slow_queries'); + }); + + Schema::create('pulse_jobs', function (Blueprint $table) { + $table->timestamp('date'); + $table->string('user_id')->nullable(); + $table->string('job'); + $table->string('job_id'); + $table->timestamp('processing_started_at', 3)->nullable(); + $table->unsignedInteger('duration')->nullable(); + + // TODO: verify this update index. Needs to find job quickly. + $table->index(['job_id']); + $table->index(['date', 'job', 'duration'], 'slow_jobs'); + $table->index(['date', 'user_id'], 'user_usage'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pulse_servers'); + Schema::dropIfExists('pulse_requests'); + Schema::dropIfExists('pulse_exceptions'); + Schema::dropIfExists('pulse_queries'); + Schema::dropIfExists('pulse_jobs'); + } +}; diff --git a/dist/pulse.css b/dist/pulse.css index e2257e8b..a0556dce 100644 --- a/dist/pulse.css +++ b/dist/pulse.css @@ -1 +1 @@ -.ct-label{fill:#0006;color:#0006;font-size:.75rem;line-height:1}.ct-chart-bar .ct-label,.ct-chart-line .ct-label{display:flex}.ct-chart-donut .ct-label,.ct-chart-pie .ct-label{dominant-baseline:central}.ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:flex-start;text-align:left}.ct-label.ct-vertical.ct-start{align-items:flex-end;justify-content:flex-end;text-align:right}.ct-label.ct-vertical.ct-end{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-chart-bar .ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:center;text-align:center}.ct-chart-bar .ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:center;text-align:center}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:flex-start;text-align:left}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start{align-items:center;justify-content:flex-end;text-align:right}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end{align-items:center;justify-content:flex-start;text-align:left}.ct-grid{stroke:#0003;stroke-width:1px;stroke-dasharray:2px}.ct-grid-background{fill:none}.ct-point{stroke-width:10px;stroke-linecap:round}.ct-line{fill:none;stroke-width:4px}.ct-area{stroke:none;fill-opacity:.1}.ct-bar{fill:none;stroke-width:10px}.ct-slice-donut{fill:none;stroke-width:60px}.ct-series-a .ct-bar,.ct-series-a .ct-line,.ct-series-a .ct-point,.ct-series-a .ct-slice-donut{stroke:#d70206}.ct-series-a .ct-area,.ct-series-a .ct-slice-pie{fill:#d70206}.ct-series-b .ct-bar,.ct-series-b .ct-line,.ct-series-b .ct-point,.ct-series-b .ct-slice-donut{stroke:#f05b4f}.ct-series-b .ct-area,.ct-series-b .ct-slice-pie{fill:#f05b4f}.ct-series-c .ct-bar,.ct-series-c .ct-line,.ct-series-c .ct-point,.ct-series-c .ct-slice-donut{stroke:#f4c63d}.ct-series-c .ct-area,.ct-series-c .ct-slice-pie{fill:#f4c63d}.ct-series-d .ct-bar,.ct-series-d .ct-line,.ct-series-d .ct-point,.ct-series-d .ct-slice-donut{stroke:#d17905}.ct-series-d .ct-area,.ct-series-d .ct-slice-pie{fill:#d17905}.ct-series-e .ct-bar,.ct-series-e .ct-line,.ct-series-e .ct-point,.ct-series-e .ct-slice-donut{stroke:#453d3f}.ct-series-e .ct-area,.ct-series-e .ct-slice-pie{fill:#453d3f}.ct-series-f .ct-bar,.ct-series-f .ct-line,.ct-series-f .ct-point,.ct-series-f .ct-slice-donut{stroke:#59922b}.ct-series-f .ct-area,.ct-series-f .ct-slice-pie{fill:#59922b}.ct-series-g .ct-bar,.ct-series-g .ct-line,.ct-series-g .ct-point,.ct-series-g .ct-slice-donut{stroke:#0544d3}.ct-series-g .ct-area,.ct-series-g .ct-slice-pie{fill:#0544d3}.ct-series-h .ct-bar,.ct-series-h .ct-line,.ct-series-h .ct-point,.ct-series-h .ct-slice-donut{stroke:#6b0392}.ct-series-h .ct-area,.ct-series-h .ct-slice-pie{fill:#6b0392}.ct-series-i .ct-bar,.ct-series-i .ct-line,.ct-series-i .ct-point,.ct-series-i .ct-slice-donut{stroke:#e6805e}.ct-series-i .ct-area,.ct-series-i .ct-slice-pie{fill:#e6805e}.ct-series-j .ct-bar,.ct-series-j .ct-line,.ct-series-j .ct-point,.ct-series-j .ct-slice-donut{stroke:#dda458}.ct-series-j .ct-area,.ct-series-j .ct-slice-pie{fill:#dda458}.ct-series-k .ct-bar,.ct-series-k .ct-line,.ct-series-k .ct-point,.ct-series-k .ct-slice-donut{stroke:#eacf7d}.ct-series-k .ct-area,.ct-series-k .ct-slice-pie{fill:#eacf7d}.ct-series-l .ct-bar,.ct-series-l .ct-line,.ct-series-l .ct-point,.ct-series-l .ct-slice-donut{stroke:#86797d}.ct-series-l .ct-area,.ct-series-l .ct-slice-pie{fill:#86797d}.ct-series-m .ct-bar,.ct-series-m .ct-line,.ct-series-m .ct-point,.ct-series-m .ct-slice-donut{stroke:#b2c326}.ct-series-m .ct-area,.ct-series-m .ct-slice-pie{fill:#b2c326}.ct-series-n .ct-bar,.ct-series-n .ct-line,.ct-series-n .ct-point,.ct-series-n .ct-slice-donut{stroke:#6188e2}.ct-series-n .ct-area,.ct-series-n .ct-slice-pie{fill:#6188e2}.ct-series-o .ct-bar,.ct-series-o .ct-line,.ct-series-o .ct-point,.ct-series-o .ct-slice-donut{stroke:#a748ca}.ct-series-o .ct-area,.ct-series-o .ct-slice-pie{fill:#a748ca}.ct-square{display:block;position:relative;width:100%}.ct-square:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:100%}.ct-square:after{content:"";display:table;clear:both}.ct-square>svg{display:block;position:absolute;top:0;left:0}.ct-minor-second{display:block;position:relative;width:100%}.ct-minor-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:93.75%}.ct-minor-second:after{content:"";display:table;clear:both}.ct-minor-second>svg{display:block;position:absolute;top:0;left:0}.ct-major-second{display:block;position:relative;width:100%}.ct-major-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:88.8888888889%}.ct-major-second:after{content:"";display:table;clear:both}.ct-major-second>svg{display:block;position:absolute;top:0;left:0}.ct-minor-third{display:block;position:relative;width:100%}.ct-minor-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:83.3333333333%}.ct-minor-third:after{content:"";display:table;clear:both}.ct-minor-third>svg{display:block;position:absolute;top:0;left:0}.ct-major-third{display:block;position:relative;width:100%}.ct-major-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:80%}.ct-major-third:after{content:"";display:table;clear:both}.ct-major-third>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fourth{display:block;position:relative;width:100%}.ct-perfect-fourth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:75%}.ct-perfect-fourth:after{content:"";display:table;clear:both}.ct-perfect-fourth>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fifth{display:block;position:relative;width:100%}.ct-perfect-fifth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:66.6666666667%}.ct-perfect-fifth:after{content:"";display:table;clear:both}.ct-perfect-fifth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-sixth{display:block;position:relative;width:100%}.ct-minor-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:62.5%}.ct-minor-sixth:after{content:"";display:table;clear:both}.ct-minor-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-golden-section{display:block;position:relative;width:100%}.ct-golden-section:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:61.804697157%}.ct-golden-section:after{content:"";display:table;clear:both}.ct-golden-section>svg{display:block;position:absolute;top:0;left:0}.ct-major-sixth{display:block;position:relative;width:100%}.ct-major-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:60%}.ct-major-sixth:after{content:"";display:table;clear:both}.ct-major-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-seventh{display:block;position:relative;width:100%}.ct-minor-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:56.25%}.ct-minor-seventh:after{content:"";display:table;clear:both}.ct-minor-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-seventh{display:block;position:relative;width:100%}.ct-major-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:53.3333333333%}.ct-major-seventh:after{content:"";display:table;clear:both}.ct-major-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-octave{display:block;position:relative;width:100%}.ct-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:50%}.ct-octave:after{content:"";display:table;clear:both}.ct-octave>svg{display:block;position:absolute;top:0;left:0}.ct-major-tenth{display:block;position:relative;width:100%}.ct-major-tenth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:40%}.ct-major-tenth:after{content:"";display:table;clear:both}.ct-major-tenth>svg{display:block;position:absolute;top:0;left:0}.ct-major-eleventh{display:block;position:relative;width:100%}.ct-major-eleventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:37.5%}.ct-major-eleventh:after{content:"";display:table;clear:both}.ct-major-eleventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-twelfth{display:block;position:relative;width:100%}.ct-major-twelfth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:33.3333333333%}.ct-major-twelfth:after{content:"";display:table;clear:both}.ct-major-twelfth>svg{display:block;position:absolute;top:0;left:0}.ct-double-octave{display:block;position:relative;width:100%}.ct-double-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:25%}.ct-double-octave:after{content:"";display:table;clear:both}.ct-double-octave>svg{display:block;position:absolute;top:0;left:0}*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[type=text],[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[type=text]:focus,[type=email]:focus,[type=url]:focus,[type=password]:focus,[type=number]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=month]:focus,[type=search]:focus,[type=tel]:focus,[type=time]:focus,[type=week]:focus,[multiple]:focus,textarea:focus,select:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple]{background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}[type=radio]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}[type=checkbox]:checked:hover,[type=checkbox]:checked:focus,[type=radio]:checked:hover,[type=radio]:checked:focus{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:indeterminate:hover,[type=checkbox]:indeterminate:focus{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:before,:after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.relative{position:relative}.sticky{position:sticky}.top-0{top:0px}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.col-span-6{grid-column:span 6 / span 6}.mx-auto{margin-left:auto;margin-right:auto}.mt-4{margin-top:1rem}.mt-2{margin-top:.5rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-6{height:1.5rem}.h-full{height:100%}.h-9{height:2.25rem}.h-8{height:2rem}.h-5{height:1.25rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-6{width:1.5rem}.w-full{width:100%}.w-32{width:8rem}.w-48{width:12rem}.w-12{width:3rem}.w-8{width:2rem}.w-5{width:1.25rem}.w-24{width:6rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.border-separate{border-collapse:separate}.border-spacing-y-2{--tw-border-spacing-y:.5rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[repeat\(4\,_auto\)\,_max-content\]{grid-template-columns:repeat(4,auto) max-content}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-10{gap:2.5rem}.gap-4{gap:1rem}.gap-2{gap:.5rem}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded-md{border-radius:.375rem}.rounded{border-radius:.25rem}.rounded-l-md{border-top-left-radius:.375rem;border-bottom-left-radius:.375rem}.rounded-r-md{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border-b{border-bottom-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.fill-none{fill:none}.fill-green-500{fill:#22c55e}.fill-red-500{fill:#ef4444}.stroke-gray-300{stroke:#d1d5db}.stroke-gray-400{stroke:#9ca3af}.stroke-green-500{stroke:#22c55e}.stroke-purple-100{stroke:#f3e8ff}.stroke-purple-600{stroke:#9333ea}.stroke-2{stroke-width:2}.p-5{padding:1.25rem}.p-4{padding:1rem}.p-2{padding:.5rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xs{font-size:.75rem;line-height:1rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39 / var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.first\:rounded-l-md:first-child{border-top-left-radius:.375rem;border-bottom-left-radius:.375rem}.last\:rounded-r-md:last-child{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.\[\&\:nth-child\(1n\+11\)\]\:border-t:nth-child(1n+11){border-top-width:1px} +.ct-label{fill:#0006;color:#0006;font-size:.75rem;line-height:1}.ct-chart-bar .ct-label,.ct-chart-line .ct-label{display:flex}.ct-chart-donut .ct-label,.ct-chart-pie .ct-label{dominant-baseline:central}.ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:flex-start;text-align:left}.ct-label.ct-vertical.ct-start{align-items:flex-end;justify-content:flex-end;text-align:right}.ct-label.ct-vertical.ct-end{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-chart-bar .ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:center;text-align:center}.ct-chart-bar .ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:center;text-align:center}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start{align-items:flex-end;justify-content:flex-start;text-align:left}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end{align-items:flex-start;justify-content:flex-start;text-align:left}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start{align-items:center;justify-content:flex-end;text-align:right}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end{align-items:center;justify-content:flex-start;text-align:left}.ct-grid{stroke:#0003;stroke-width:1px;stroke-dasharray:2px}.ct-grid-background{fill:none}.ct-point{stroke-width:10px;stroke-linecap:round}.ct-line{fill:none;stroke-width:4px}.ct-area{stroke:none;fill-opacity:.1}.ct-bar{fill:none;stroke-width:10px}.ct-slice-donut{fill:none;stroke-width:60px}.ct-series-a .ct-bar,.ct-series-a .ct-line,.ct-series-a .ct-point,.ct-series-a .ct-slice-donut{stroke:#d70206}.ct-series-a .ct-area,.ct-series-a .ct-slice-pie{fill:#d70206}.ct-series-b .ct-bar,.ct-series-b .ct-line,.ct-series-b .ct-point,.ct-series-b .ct-slice-donut{stroke:#f05b4f}.ct-series-b .ct-area,.ct-series-b .ct-slice-pie{fill:#f05b4f}.ct-series-c .ct-bar,.ct-series-c .ct-line,.ct-series-c .ct-point,.ct-series-c .ct-slice-donut{stroke:#f4c63d}.ct-series-c .ct-area,.ct-series-c .ct-slice-pie{fill:#f4c63d}.ct-series-d .ct-bar,.ct-series-d .ct-line,.ct-series-d .ct-point,.ct-series-d .ct-slice-donut{stroke:#d17905}.ct-series-d .ct-area,.ct-series-d .ct-slice-pie{fill:#d17905}.ct-series-e .ct-bar,.ct-series-e .ct-line,.ct-series-e .ct-point,.ct-series-e .ct-slice-donut{stroke:#453d3f}.ct-series-e .ct-area,.ct-series-e .ct-slice-pie{fill:#453d3f}.ct-series-f .ct-bar,.ct-series-f .ct-line,.ct-series-f .ct-point,.ct-series-f .ct-slice-donut{stroke:#59922b}.ct-series-f .ct-area,.ct-series-f .ct-slice-pie{fill:#59922b}.ct-series-g .ct-bar,.ct-series-g .ct-line,.ct-series-g .ct-point,.ct-series-g .ct-slice-donut{stroke:#0544d3}.ct-series-g .ct-area,.ct-series-g .ct-slice-pie{fill:#0544d3}.ct-series-h .ct-bar,.ct-series-h .ct-line,.ct-series-h .ct-point,.ct-series-h .ct-slice-donut{stroke:#6b0392}.ct-series-h .ct-area,.ct-series-h .ct-slice-pie{fill:#6b0392}.ct-series-i .ct-bar,.ct-series-i .ct-line,.ct-series-i .ct-point,.ct-series-i .ct-slice-donut{stroke:#e6805e}.ct-series-i .ct-area,.ct-series-i .ct-slice-pie{fill:#e6805e}.ct-series-j .ct-bar,.ct-series-j .ct-line,.ct-series-j .ct-point,.ct-series-j .ct-slice-donut{stroke:#dda458}.ct-series-j .ct-area,.ct-series-j .ct-slice-pie{fill:#dda458}.ct-series-k .ct-bar,.ct-series-k .ct-line,.ct-series-k .ct-point,.ct-series-k .ct-slice-donut{stroke:#eacf7d}.ct-series-k .ct-area,.ct-series-k .ct-slice-pie{fill:#eacf7d}.ct-series-l .ct-bar,.ct-series-l .ct-line,.ct-series-l .ct-point,.ct-series-l .ct-slice-donut{stroke:#86797d}.ct-series-l .ct-area,.ct-series-l .ct-slice-pie{fill:#86797d}.ct-series-m .ct-bar,.ct-series-m .ct-line,.ct-series-m .ct-point,.ct-series-m .ct-slice-donut{stroke:#b2c326}.ct-series-m .ct-area,.ct-series-m .ct-slice-pie{fill:#b2c326}.ct-series-n .ct-bar,.ct-series-n .ct-line,.ct-series-n .ct-point,.ct-series-n .ct-slice-donut{stroke:#6188e2}.ct-series-n .ct-area,.ct-series-n .ct-slice-pie{fill:#6188e2}.ct-series-o .ct-bar,.ct-series-o .ct-line,.ct-series-o .ct-point,.ct-series-o .ct-slice-donut{stroke:#a748ca}.ct-series-o .ct-area,.ct-series-o .ct-slice-pie{fill:#a748ca}.ct-square{display:block;position:relative;width:100%}.ct-square:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:100%}.ct-square:after{content:"";display:table;clear:both}.ct-square>svg{display:block;position:absolute;top:0;left:0}.ct-minor-second{display:block;position:relative;width:100%}.ct-minor-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:93.75%}.ct-minor-second:after{content:"";display:table;clear:both}.ct-minor-second>svg{display:block;position:absolute;top:0;left:0}.ct-major-second{display:block;position:relative;width:100%}.ct-major-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:88.8888888889%}.ct-major-second:after{content:"";display:table;clear:both}.ct-major-second>svg{display:block;position:absolute;top:0;left:0}.ct-minor-third{display:block;position:relative;width:100%}.ct-minor-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:83.3333333333%}.ct-minor-third:after{content:"";display:table;clear:both}.ct-minor-third>svg{display:block;position:absolute;top:0;left:0}.ct-major-third{display:block;position:relative;width:100%}.ct-major-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:80%}.ct-major-third:after{content:"";display:table;clear:both}.ct-major-third>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fourth{display:block;position:relative;width:100%}.ct-perfect-fourth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:75%}.ct-perfect-fourth:after{content:"";display:table;clear:both}.ct-perfect-fourth>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fifth{display:block;position:relative;width:100%}.ct-perfect-fifth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:66.6666666667%}.ct-perfect-fifth:after{content:"";display:table;clear:both}.ct-perfect-fifth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-sixth{display:block;position:relative;width:100%}.ct-minor-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:62.5%}.ct-minor-sixth:after{content:"";display:table;clear:both}.ct-minor-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-golden-section{display:block;position:relative;width:100%}.ct-golden-section:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:61.804697157%}.ct-golden-section:after{content:"";display:table;clear:both}.ct-golden-section>svg{display:block;position:absolute;top:0;left:0}.ct-major-sixth{display:block;position:relative;width:100%}.ct-major-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:60%}.ct-major-sixth:after{content:"";display:table;clear:both}.ct-major-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-seventh{display:block;position:relative;width:100%}.ct-minor-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:56.25%}.ct-minor-seventh:after{content:"";display:table;clear:both}.ct-minor-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-seventh{display:block;position:relative;width:100%}.ct-major-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:53.3333333333%}.ct-major-seventh:after{content:"";display:table;clear:both}.ct-major-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-octave{display:block;position:relative;width:100%}.ct-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:50%}.ct-octave:after{content:"";display:table;clear:both}.ct-octave>svg{display:block;position:absolute;top:0;left:0}.ct-major-tenth{display:block;position:relative;width:100%}.ct-major-tenth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:40%}.ct-major-tenth:after{content:"";display:table;clear:both}.ct-major-tenth>svg{display:block;position:absolute;top:0;left:0}.ct-major-eleventh{display:block;position:relative;width:100%}.ct-major-eleventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:37.5%}.ct-major-eleventh:after{content:"";display:table;clear:both}.ct-major-eleventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-twelfth{display:block;position:relative;width:100%}.ct-major-twelfth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:33.3333333333%}.ct-major-twelfth:after{content:"";display:table;clear:both}.ct-major-twelfth>svg{display:block;position:absolute;top:0;left:0}.ct-double-octave{display:block;position:relative;width:100%}.ct-double-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:25%}.ct-double-octave:after{content:"";display:table;clear:both}.ct-double-octave>svg{display:block;position:absolute;top:0;left:0}*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[type=text],[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[type=text]:focus,[type=email]:focus,[type=url]:focus,[type=password]:focus,[type=number]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=month]:focus,[type=search]:focus,[type=tel]:focus,[type=time]:focus,[type=week]:focus,[multiple]:focus,textarea:focus,select:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple]{background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}[type=radio]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}[type=checkbox]:checked:hover,[type=checkbox]:checked:focus,[type=radio]:checked:hover,[type=radio]:checked:focus{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:indeterminate:hover,[type=checkbox]:indeterminate:focus{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:before,:after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.relative{position:relative}.sticky{position:sticky}.top-0{top:0px}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.col-span-6{grid-column:span 6 / span 6}.mx-auto{margin-left:auto;margin-right:auto}.mt-4{margin-top:1rem}.mt-2{margin-top:.5rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.mr-1{margin-right:.25rem}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-6{height:1.5rem}.h-full{height:100%}.h-5{height:1.25rem}.h-1{height:.25rem}.h-9{height:2.25rem}.h-8{height:2rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-6{width:1.5rem}.w-full{width:100%}.w-5{width:1.25rem}.w-1{width:.25rem}.w-32{width:8rem}.w-48{width:12rem}.w-12{width:3rem}.w-8{width:2rem}.w-24{width:6rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.border-separate{border-collapse:separate}.border-spacing-y-2{--tw-border-spacing-y:.5rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[max-content\,_repeat\(4\,_auto\)\]{grid-template-columns:max-content repeat(4,auto)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded-md{border-radius:.375rem}.rounded-full{border-radius:9999px}.rounded{border-radius:.25rem}.rounded-l-md{border-top-left-radius:.375rem;border-bottom-left-radius:.375rem}.rounded-r-md{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border-b{border-bottom-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94 / var(--tw-bg-opacity))}.fill-red-500{fill:#ef4444}.fill-none{fill:none}.stroke-gray-300{stroke:#d1d5db}.stroke-gray-400{stroke:#9ca3af}.stroke-purple-600{stroke:#9333ea}.stroke-purple-100{stroke:#f3e8ff}.stroke-2{stroke-width:2}.p-5{padding:1.25rem}.p-4{padding:1rem}.p-2{padding:.5rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-base{font-size:1rem;line-height:1.5rem}.text-xs{font-size:.75rem;line-height:1rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-25{opacity:.25}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}[x-cloak]{display:none}.first\:rounded-l-md:first-child{border-top-left-radius:.375rem;border-bottom-left-radius:.375rem}.last\:rounded-r-md:last-child{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.hover\:text-gray-400:hover{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.\[\&\:nth-child\(1n\+11\)\]\:border-t:nth-child(1n+11){border-top-width:1px} diff --git a/resources/css/pulse.css b/resources/css/pulse.css index b67dbc02..a7239e3c 100644 --- a/resources/css/pulse.css +++ b/resources/css/pulse.css @@ -3,3 +3,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +[x-cloak] { + display: none; +} diff --git a/resources/views/components/loading-indicator.blade.php b/resources/views/components/loading-indicator.blade.php new file mode 100644 index 00000000..9fb97cc6 --- /dev/null +++ b/resources/views/components/loading-indicator.blade.php @@ -0,0 +1,5 @@ +
+
+ LOADING +
+
diff --git a/resources/views/components/pulse.blade.php b/resources/views/components/pulse.blade.php index 3d627caa..0a0c96e9 100644 --- a/resources/views/components/pulse.blade.php +++ b/resources/views/components/pulse.blade.php @@ -37,6 +37,7 @@ Laravel Pulse + diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index b87eeb73..07f05247 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -2,8 +2,9 @@ - + + diff --git a/resources/views/livewire/exceptions.blade.php b/resources/views/livewire/exceptions.blade.php index c9ee9ab8..7fd453f5 100644 --- a/resources/views/livewire/exceptions.blade.php +++ b/resources/views/livewire/exceptions.blade.php @@ -1,56 +1,90 @@ - + - Exceptions - Past 7 days + Exceptions + past {{ match ($this->period) { + '6_hours' => '6 hours', + '24_hours' => '24 hours', + '7_days' => '7 days', + default => 'hour', + } }} - +
+
Sort by
+ +
-
- @if (count($exceptions) === 0) - - @else - - - - Type - Latest - Count - - - - @foreach ($exceptions as $exception) - - - - {{ $exception['class'] }} - -

- {{ $exception['location'] }} -

-
- - {{ $exception['last_occurrence'] !== null ? Carbon\Carbon::parse($exception['last_occurrence'])->fromNow() : 'Unknown' }} - - - {{ $exception['count'] }} - - - @endforeach - -
- @endif +
+ +
+ +
+
+ @if ($initialDataLoaded && count($exceptions) === 0) + + @elseif ($initialDataLoaded && count($exceptions) > 0) + + + + Type + Latest + Count + + + + @foreach ($exceptions as $exception) + + + + {{ $exception->class }} + +

+ {{ $exception->location }} +

+
+ + {{ $exception->last_occurrence !== null ? Carbon\Carbon::parse($exception->last_occurrence)->fromNow() : 'Unknown' }} + + + {{ $exception->count }} + + + @endforeach + +
+ @endif +
+
+
diff --git a/resources/views/livewire/period-selector.blade.php b/resources/views/livewire/period-selector.blade.php new file mode 100644 index 00000000..740b1afd --- /dev/null +++ b/resources/views/livewire/period-selector.blade.php @@ -0,0 +1,6 @@ +
+ + + + +
diff --git a/resources/views/livewire/servers.blade.php b/resources/views/livewire/servers.blade.php index 1202efa6..1c67b592 100644 --- a/resources/views/livewire/servers.blade.php +++ b/resources/views/livewire/servers.blade.php @@ -11,51 +11,59 @@ @endphp @if ($servers)
-
+
+
Memory
CPU
Storage
-
- @foreach ($servers as $slug => $server) - @php - $lastReading = collect($server['readings'])->last(); - @endphp + @foreach ($servers as $server) +
+ @if ($server->updated_at->lt(now()->subSeconds(30))) + + + + @else +
+
+
+ @endif +
- {{ $server['name'] }} + {{ $server->name }}
- - {{ $friendlySize($lastReading['memory_used'], 1) }} + + {{ $friendlySize($server->memory_used, 1) }} - / {{ $friendlySize($lastReading['memory_total'], 1) }} + / {{ $friendlySize($server->memory_total, 1) }}
-
+
@push('scripts') +
+ +
+
+ @if ($initialDataLoaded && count($slowJobs) === 0) + + @elseif ($initialDataLoaded && count($slowJobs) > 0) + + + + Job + Count + Slowest + + + + @foreach ($slowJobs as $job) + + + + {{ $job->job }} + + + + {{ $job->count }} + + + @if ($job->slowest === null) + Unknown + @else + {{ $job->slowest ?: '<1' }} ms + @endif + + + @endforeach + + + @endif +
+
+
+
+ diff --git a/resources/views/livewire/slow-queries.blade.php b/resources/views/livewire/slow-queries.blade.php index 2617f3bf..08882235 100644 --- a/resources/views/livewire/slow-queries.blade.php +++ b/resources/views/livewire/slow-queries.blade.php @@ -1,61 +1,80 @@ - + - Slow Queries - >={{ config('pulse.slow_query_threshold') }}ms, past 7 days + Slow Queries + past {{ match ($this->period) { + '6_hours' => '6 hours', + '24_hours' => '24 hours', + '7_days' => '7 days', + default => 'hour', + } }}, >={{ config('pulse.slow_query_threshold') }}ms - @if (count($slowQueries) === 0) - - @else -
- - - - Query - Count - Average - Slowest - - - - @foreach ($slowQueries as $query) - - - - {{ $query['sql'] }} - - - - {{ $query['execution_count'] }} - - - @if ($query['average_duration'] === null) - Unknown - @else - {{ $query['average_duration'] ?: '<1' }} ms - @endif - - - @if ($query['slowest_duration'] === null) - Unknown - @else - {{ $query['slowest_duration'] ?: '<1' }} ms - @endif - - - @endforeach - - +
+ +
+ +
+
+ @if ($initialDataLoaded && count($slowQueries) === 0) + + @elseif ($initialDataLoaded && count($slowQueries) > 0) + + + + Query + Count + Slowest + + + + @foreach ($slowQueries as $query) + + + + {{ $query->sql }} + + + + {{ $query->count }} + + + @if ($query->slowest === null) + Unknown + @else + {{ $query->slowest ?: '<1' }} ms + @endif + + + @endforeach + + + @endif +
+
- @endif +
diff --git a/resources/views/livewire/slow-routes.blade.php b/resources/views/livewire/slow-routes.blade.php new file mode 100644 index 00000000..474f53c5 --- /dev/null +++ b/resources/views/livewire/slow-routes.blade.php @@ -0,0 +1,83 @@ + + + + + + + + Slow Routes + past {{ match ($this->period) { + '6_hours' => '6 hours', + '24_hours' => '24 hours', + '7_days' => '7 days', + default => 'hour', + } }}, >={{ config('pulse.slow_endpoint_threshold') }}ms + + + + +
+ +
+ +
+
+ @if ($initialDataLoaded && count($slowRoutes) === 0) + + @elseif ($initialDataLoaded && count($slowRoutes) > 0) + + + + Route + Count + Slowest + + + + @foreach ($slowRoutes as $route) + + + + {{ $route['uri'] }} + +

+ {{ $route['action'] }} +

+
+ + {{ $route['request_count'] }} + + + @if ($route['slowest_duration'] === null) + Unknown + @else + {{ $route['slowest_duration'] ?: '<1' }} ms + @endif + + + @endforeach + +
+ @endif +
+
+
+
+
diff --git a/resources/views/livewire/usage.blade.php b/resources/views/livewire/usage.blade.php index 529c973a..503f2c33 100644 --- a/resources/views/livewire/usage.blade.php +++ b/resources/views/livewire/usage.blade.php @@ -1,77 +1,87 @@ - + - Application Usage - Past 7 days + Application Usage + past {{ match ($this->period) { + '6_hours' => '6 hours', + '24_hours' => '24 hours', + '7_days' => '7 days', + default => 'hour', + } }}@if ($this->usage === 'slow_endpoint_counts'), >={{ config('pulse.slow_endpoint_threshold') }}ms @endif
Top 10 users
-
- @if ($view === 'request-counts') - @if (count($userRequestCounts) === 0) - - @else -
- @foreach ($userRequestCounts as $userRequestCount) -
-
-
- {{ $userRequestCount['user']['name'] }} -
-
- {{ $userRequestCount['user']['email'] }} -
-
-
- - {{ $userRequestCount['count'] }} - -
-
- @endforeach -
- @endif - @elseif ($view === 'slow-endpoint-counts') - @if (count($usersExperiencingSlowEndpoints) === 0) - - @else -
- @foreach ($usersExperiencingSlowEndpoints as $userExperiencingSlowEndpoints) -
-
-
- {{ $userExperiencingSlowEndpoints['user']['name'] }} -
-
- {{ $userExperiencingSlowEndpoints['user']['email'] }} +
+ +
+ +
+
+ @if ($initialDataLoaded && count($userRequestCounts) === 0) + + @elseif ($initialDataLoaded && count($userRequestCounts) > 0) +
+ @foreach ($userRequestCounts ?? [] as $userRequestCount) +
+
+
+ {{ $userRequestCount['user']['name'] }} +
+
+ {{ $userRequestCount['user']['email'] }} +
+
+
+ + {{ $userRequestCount['count'] }} + +
-
-
- {{ $userExperiencingSlowEndpoints['count'] }} -
+ @endforeach
- @endforeach + @endif
- @endif - @endif +
+
diff --git a/src/Commands/CheckCommand.php b/src/Commands/CheckCommand.php index 6eb297e2..312b1b13 100644 --- a/src/Commands/CheckCommand.php +++ b/src/Commands/CheckCommand.php @@ -3,8 +3,7 @@ namespace Laravel\Pulse\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Str; -use Laravel\Pulse\RedisAdapter; +use Illuminate\Support\Facades\DB; use RuntimeException; class CheckCommand extends Command @@ -26,16 +25,26 @@ class CheckCommand extends Command /** * Handle the command. * - * @return int + * @return void */ public function handle() { - $slug = Str::slug(config('pulse.server_name')); - RedisAdapter::hset('pulse_servers', $slug, config('pulse.server_name')); + $lastSnapshotAt = now()->floorSeconds(15); while (true) { + $now = now()->toImmutable(); + + if ($now->subSeconds(15)->lessThan($lastSnapshotAt)) { + sleep(1); + + continue; + } + + $lastSnapshotAt = $now->floorSeconds(15); + $stats = [ - 'timestamp' => now()->timestamp, + 'date' => $lastSnapshotAt->toDateTimeString(), + 'server' => config('pulse.server_name'), ...$this->getStats(), 'storage' => collect(config('pulse.directories'))->map(fn ($directory) => [ 'directory' => $directory, @@ -44,12 +53,9 @@ public function handle() ])->toJson(), ]; - RedisAdapter::xadd("pulse_servers:{$slug}", $stats); - RedisAdapter::xtrim("pulse_servers:{$slug}", 60); + DB::table('pulse_servers')->insert($stats); $this->line(json_encode($stats)); - - sleep(2); } } @@ -65,7 +71,7 @@ protected function getStats() protected function getDarwinStats() { return [ - 'cpu' => (int) `top -l 1 | grep -E "^CPU" | tail -1 | awk '{ print $3 + $5 }'`, + 'cpu_percent' => (int) `top -l 1 | grep -E "^CPU" | tail -1 | awk '{ print $3 + $5 }'`, 'memory_total' => $memoryTotal = intval(`sysctl hw.memsize | grep -Eo '[0-9]+'` / 1024 / 1024), // MB 'memory_used' => $memoryTotal - intval(intval(`vm_stat | grep 'Pages free' | grep -Eo '[0-9]+'`) * intval(`pagesize`) / 1024 / 1024), // MB ]; @@ -74,9 +80,9 @@ protected function getDarwinStats() protected function getLinuxStats() { return [ - 'cpu' => (int) `top -bn1 | grep '%Cpu(s)' | tail -1 | grep -Eo '[0-9]+\.[0-9]+' | head -n 4 | tail -1 | awk '{ print 100 - $1 }'`, - 'memory_total' => $memTotal = intval(`cat /proc/meminfo | grep MemTotal | grep -E -o '[0-9]+'` / 1024), // MB - 'memory_used' => $memTotal - intval(`cat /proc/meminfo | grep MemAvailable | grep -E -o '[0-9]+'` / 1024), // MB + 'cpu_percent' => (int) `top -bn1 | grep '%Cpu(s)' | tail -1 | grep -Eo '[0-9]+\.[0-9]+' | head -n 4 | tail -1 | awk '{ print 100 - $1 }'`, + 'memory_total' => $memoryTotal = intval(`cat /proc/meminfo | grep MemTotal | grep -E -o '[0-9]+'` / 1024), // MB + 'memory_used' => $memoryTotal - intval(`cat /proc/meminfo | grep MemAvailable | grep -E -o '[0-9]+'` / 1024), // MB ]; } } diff --git a/src/Commands/WorkCommand.php b/src/Commands/WorkCommand.php new file mode 100644 index 00000000..a030443d --- /dev/null +++ b/src/Commands/WorkCommand.php @@ -0,0 +1,287 @@ +now(); + + dump('redisNow: '.$redisNow->format('Y-m-d H:i:s v')); + + // Get the latest date from the database + $lastDate = DB::table('pulse_requests') + ->where('resolution', 5) + ->max('date'); + + dump('lastDate: '.$lastDate); + + if ($lastDate !== null) { + dump('lastDate found'); + $from = CarbonImmutable::parse($lastDate)->addSeconds(5); + } else { + dump('No last date, starting 7 days ago from redisNow'); + $from = $redisNow->subDays(7)->floorSeconds(5); + } + + dump('from: '.$from->format('Y-m-d H:i:s v')); + $from = $from->getTimestampMs(); + + $requests = collect(); + while (true) { + $redisNow = $redis->now(); + $newRequests = collect($redis->xrange('pulse_requests', $from, '+', 1000)); + echo '.'; + $requests = $requests->merge($newRequests); + + if ($requests->count() > 0) { + $from = '(' . $requests->keys()->last(); + } + + $aggregates = collect(); + while ($requests->count() > 0) { + $firstKey = $requests->keys()->first(); + $bucketStart = CarbonImmutable::createFromTimestampMs(Str::before($firstKey, '-'))->floorSeconds(5); + $maxKey = $bucketStart->addSeconds(4)->endOfSecond()->getTimestampMs(); + // dump($firstKey, $lastKey); + + $bucket = $requests->takeWhile(function ($item, $key) use ($maxKey) { + $time = Str::before($key, '-'); + return $time <= $maxKey; + }); + + if ($bucket->count() === $requests->count() && $redisNow->getTimestampMs() < $maxKey) { + break 1; + } + + $aggregates = $aggregates->merge($this->getAggregates($bucketStart, $bucket)); + $requests = $requests->skip($bucket->count()); + dump("saving bucket of {$bucket->count()} requests"); + } + + if ($aggregates->count() > 0) { + dump('inserting records...'); + foreach ($aggregates->chunk(1000) as $chunk) { + DB::table('pulse_requests')->insert($chunk->all()); + } + } + + // + + if ($newRequests->count() < 1000) { + dump('agging 60...'); + $latest60Date = DB::table('pulse_requests')->where('resolution', 60)->latest('date')->value('date'); + dump('latest60Date: '.$latest60Date); + if ($latest60Date) { + $latest60Date = CarbonImmutable::parse($latest60Date)->addMinute()->format('Y-m-d H:i:s'); + } + $dateSql = $latest60Date ? "AND date >= '{$latest60Date}'" : ''; + $dateSql .= " AND date < '{$redisNow->startOfMinute()->format('Y-m-d H:i:s')}'"; + dump($dateSql); + + dump(Benchmark::measure(fn () => + DB::statement(<<<"SQL" + INSERT INTO pulse_requests (date, resolution, user_id, route, volume, average, slowest) + SELECT bucket, 60, user_id, route, volume, average, slowest + FROM ( + SELECT + bucket, + user_id, + route, + SUM(`volume`) as `volume`, + AVG(`average`) as `average`, + MAX(`slowest`) as `slowest` + FROM ( + SELECT + *, + DATE_FORMAT(`date`, '%Y-%m-%d %H:%i:00') as `bucket` + FROM `pulse_requests` + WHERE resolution = 5 + {$dateSql} + ) as `sub` + GROUP BY `bucket` ,`user_id` ,`route` + ORDER BY `bucket` ASC + ) AS agged + SQL))); + + dump('agging 600...'); + $latest600Date = DB::table('pulse_requests')->where('resolution', 600)->latest('date')->value('date'); + dump('latest600Date: '.$latest600Date); + if ($latest600Date) { + $latest600Date = CarbonImmutable::parse($latest600Date)->addMinute(10)->format('Y-m-d H:i:s'); + } + $dateSql = $latest600Date ? "AND date >= '{$latest600Date}'" : ''; + $dateSql .= " AND date < '{$redisNow->startOfMinute()->floorMinute(10)->format('Y-m-d H:i:s')}'"; + dump($dateSql); + + dump(Benchmark::measure(fn () => + DB::statement(<<<"SQL" + INSERT INTO pulse_requests (date, resolution, user_id, route, volume, average, slowest) + SELECT bucket, 600, user_id, route, volume, average, slowest + FROM ( + SELECT + bucket, + user_id, + route, + SUM(`volume`) as `volume`, + AVG(`average`) as `average`, + MAX(`slowest`) as `slowest` + FROM ( + SELECT + *, + DATE_ADD(DATE_FORMAT(date, '%Y-%m-%d %H:00:00'), INTERVAL MINUTE(date) - MOD(MINUTE(date), 10) MINUTE) as `bucket` + FROM `pulse_requests` + WHERE resolution = 60 + {$dateSql} + ) as `sub` + GROUP BY `bucket` ,`user_id` ,`route` + ORDER BY `bucket` ASC + ) AS agged + SQL))); + + sleep(5); + } + } + } + + /** + * Handle the command. + * + * @return int + */ + public function handlex(Redis $redis) + { + // Database may have nothing or may have existing records. + // Stream may have nothing or may have existing records. + // Need to backfill database from stream. + // Need to make sure we have a full 5 seconds. + // TODO: Add test for millisecond boundaries + + $redisNow = $redis->now(); + + dump('redisNow: '.$redisNow->format('Y-m-d H:i:s v')); + + // Get the latest date from the database + $lastDate = DB::table('pulse_requests') + ->where('resolution', 5) + ->max('date'); + + dump('lastDate: '.$lastDate); + + // dd($redis->xrange('pulse_requests', '-', '+')); + + // Back fill the database from the stream + + // If there is nothing in the database, start from the oldest record in the stream, or 7 days ago, which ever is closest. + if ($lastDate === null) { + dump('No last date, getting oldest stream key...'); + + $oldestStreamDate = $redis->oldestStreamEntryDate('pulse_requests'); + + if ($oldestStreamDate) { + dump('oldestStreamDate: '.$oldestStreamDate->format('Y-m-d H:i:s v')); + $from = $redisNow->subDays(7)->max($oldestStreamDate); + dump('from: '.$from->format('Y-m-d H:i:s v')); + } else { + dd('no data in the stream'); + } + } else { + dump('lastDate found'); + $from = CarbonImmutable::parse($lastDate)->addSeconds(5); + dump('from: '.$from->format('Y-m-d H:i:s v')); + } + + $from = $from->ceilSeconds(5); // 20:00:00 000 + + $to = $from->addSeconds(4)->endOfSecond(); // 20:00:04 999 + + $aggregates = collect([]); + while ($to->lte($redisNow->floorSeconds(5))) { + $aggregates = $aggregates->merge($this->getAggregates($from, $to)); + // $aggregates->merge(dump($this->getAggregates($from, $to))); + $from = $from->addSeconds(5); + $to = $to->addSeconds(5); + } + + dump('count: '.$aggregates->count()); + + if ($aggregates->count() > 0) { + dump('inserting records...'); + foreach ($aggregates->chunk(1000) as $chunk) { + DB::table('pulse_requests')->insert($chunk->toArray()); + } + } + + // DB::table('pulse_requests')->insert(array_merge(...$allAggregates)); + + // foreach ($allAggregates as $aggregate) { + // dump($aggregate); + // } + + // $latestStream = array_key_first($redis->xrevrange('pulse_requests', '+', '-', 1)); + // dd($oldestStream, $latestStream); + + // $allRequests = collect($redis->xrange('pulse_requests', '-', '+')); + // dump($allRequests->keys()->first(), $allRequests->keys()->last(), $allRequests->count()); + + } + + protected function getAggregates($from, $requests) + { + $counts = []; + foreach ($requests as $request) { + $counts[$request['route']][$request['user_id'] ?: '0'][] = $request['duration']; + } + + $aggregates = []; + foreach ($counts as $route => $userDurations) { + foreach ($userDurations as $user => $durations) { + $durations = collect($durations); + + $aggregates[] = [ + 'date' => $from->format('Y-m-d H:i:s'), + 'resolution' => 5, + 'route' => $route, + 'user_id' => $user ?: null, + 'volume' => $durations->count(), + 'average' => $durations->average(), + 'slowest' => (int) $durations->max(), + ]; + } + } + + return $aggregates; + } +} diff --git a/src/Handlers/HandleException.php b/src/Handlers/HandleException.php index 56048a52..2f70db11 100644 --- a/src/Handlers/HandleException.php +++ b/src/Handlers/HandleException.php @@ -2,8 +2,10 @@ namespace Laravel\Pulse\Handlers; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Lottery; use Illuminate\Support\Str; -use Laravel\Pulse\RedisAdapter; use Throwable; class HandleException @@ -25,21 +27,16 @@ public function __invoke(Throwable $e): void */ protected function recordException(Throwable $e) { - $keyDate = now()->format('Y-m-d'); - $keyExpiry = now()->toImmutable()->startOfDay()->addDays(7)->timestamp; - - $exception = json_encode([ + DB::table('pulse_exceptions')->insert([ + 'date' => now()->toDateTimeString(), + 'user_id' => Auth::id(), 'class' => get_class($e), 'location' => $this->getLocation($e), ]); - $countKey = "pulse_exception_counts:{$keyDate}"; - RedisAdapter::zincrby($countKey, 1, $exception); - RedisAdapter::expireat($countKey, $keyExpiry, 'NX'); - - $lastOccurrenceKey = "pulse_exception_last_occurrences:{$keyDate}"; - RedisAdapter::zadd($lastOccurrenceKey, now()->timestamp, $exception); - RedisAdapter::expireat($lastOccurrenceKey, $keyExpiry, 'NX'); + // Lottery::odds(1, 100)->winner(fn () => + // DB::table('pulse_exceptions')->where('date', '<', now()->subDays(7)->toDateTimeString())->delete() + // )->choose(); } /** diff --git a/src/Handlers/HandleHttpRequest.php b/src/Handlers/HandleHttpRequest.php index 9d22f329..df88c44a 100644 --- a/src/Handlers/HandleHttpRequest.php +++ b/src/Handlers/HandleHttpRequest.php @@ -4,53 +4,41 @@ use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Lottery; +use Illuminate\Support\Str; use Laravel\Pulse\Pulse; -use Laravel\Pulse\RedisAdapter; use Symfony\Component\HttpFoundation\Response; class HandleHttpRequest { + /** + * Create a handler instance. + */ + public function __construct( + protected Pulse $pulse, + ) { + // + } + /** * Handle the completion of an HTTP request. */ public function __invoke(Carbon $startedAt, Request $request, Response $response): void { - $duration = $startedAt->diffInMilliseconds(now()); - $route = $request->method().' '.($request->route()?->uri() ?? $request->path()); - - $keyDate = $startedAt->format('Y-m-d'); - $keyExpiry = $startedAt->toImmutable()->startOfDay()->addDays(7)->timestamp; - - // Slow endpoint - if ($duration >= config('pulse.slow_endpoint_threshold')) { - $countKey = "pulse_slow_endpoint_request_counts:{$keyDate}"; - RedisAdapter::zincrby($countKey, 1, $route); - RedisAdapter::expireat($countKey, $keyExpiry, 'NX'); - - $durationKey = "pulse_slow_endpoint_total_durations:{$keyDate}"; - RedisAdapter::zincrby($durationKey, $duration, $route); - RedisAdapter::expireat($durationKey, $keyExpiry, 'NX'); - - $slowestKey = "pulse_slow_endpoint_slowest_durations:{$keyDate}"; - RedisAdapter::zadd($slowestKey, $duration, $route, 'GT'); - RedisAdapter::expireat($slowestKey, $keyExpiry, 'NX'); - - if ($request->user()) { - $userKey = "pulse_slow_endpoint_user_counts:{$keyDate}"; - RedisAdapter::zincrby($userKey, 1, $request->user()->id); - RedisAdapter::expireat($userKey, $keyExpiry, 'NX'); - } - } - - if (app(Pulse::class)->doNotReportUsage) { + if ($this->pulse->doNotReportUsage) { return; } - // Top 10 users hitting the application - if ($request->user()) { - $hitsKey = "pulse_user_request_counts:{$keyDate}"; - RedisAdapter::zincrby($hitsKey, 1, $request->user()->id); - RedisAdapter::expireAt($hitsKey, $keyExpiry, 'NX'); - } + DB::table('pulse_requests')->insert([ + 'date' => $startedAt->toDateTimeString(), + 'user_id' => $request->user()?->id, + 'route' => $request->method().' '.Str::start(($request->route()?->uri() ?? $request->path()), '/'), + 'duration' => $startedAt->diffInMilliseconds(now()), + ]); + + // Lottery::odds(1, 100)->winner(fn () => + // DB::table('pulse_requests')->where('date', '<', now()->subDays(7)->toDateTimeString())->delete() + // )->choose(); } } diff --git a/src/Handlers/HandleProcessedJob.php b/src/Handlers/HandleProcessedJob.php new file mode 100644 index 00000000..45684910 --- /dev/null +++ b/src/Handlers/HandleProcessedJob.php @@ -0,0 +1,45 @@ +pulse->doNotReportUsage) { + return; + } + + // TODO: this should capture "now()", but using a random duration to improve + // the randomness without having to have long running jobs. + $now = now()->addMilliseconds(rand(100, 10000)); + + // TODO respect slow limit configuration + + DB::table('pulse_jobs') + ->where('job_id', (string) $event->job->getJobId()) + ->update([ + 'duration' => DB::raw('TIMESTAMPDIFF(MICROSECOND, `processing_started_at`, "'.$now->toDateTimeString('millisecond').'") / 1000'), + ]); + } +} diff --git a/src/Handlers/HandleProcessingJob.php b/src/Handlers/HandleProcessingJob.php new file mode 100644 index 00000000..690e0c58 --- /dev/null +++ b/src/Handlers/HandleProcessingJob.php @@ -0,0 +1,44 @@ +pulse->doNotReportUsage) { + return; + } + + DB::table('pulse_jobs') + ->where('job_id', (string) $event->job->getJobId()) + ->update([ + 'processing_started_at' => now()->toDateTimeString('millisecond'), + ]); + + // Lottery::odds(1, 100)->winner(fn () => + // DB::table('pulse_jobs')->where('date', '<', now()->subDays(7)->toDateTimeString())->delete() + // )->choose(); + } +} diff --git a/src/Handlers/HandleQuery.php b/src/Handlers/HandleQuery.php index ba886b56..26f805e3 100644 --- a/src/Handlers/HandleQuery.php +++ b/src/Handlers/HandleQuery.php @@ -3,34 +3,44 @@ namespace Laravel\Pulse\Handlers; use Illuminate\Database\Events\QueryExecuted; -use Laravel\Pulse\RedisAdapter; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Lottery; +use Laravel\Pulse\Pulse; class HandleQuery { + /** + * Create a handler instance. + */ + public function __construct( + protected Pulse $pulse, + ) { + // + } + /** * Handle the execution of a database query. */ public function __invoke(QueryExecuted $event): void { - if ($event->time < config('pulse.slow_query_threshold')) { + if ($this->pulse->doNotReportUsage) { return; } - // TODO: Capture where the query came from? Won't always be userland. - - $keyDate = now()->format('Y-m-d'); - $keyExpiry = now()->startOfDay()->addDays(7)->timestamp; - - $countKey = "pulse_slow_query_execution_counts:{$keyDate}"; - RedisAdapter::zincrby($countKey, 1, $event->sql); - RedisAdapter::expireat($countKey, $keyExpiry, 'NX'); + if ($event->time < config('pulse.slow_query_threshold')) { + return; + } - $durationKey = "pulse_slow_query_total_durations:{$keyDate}"; - RedisAdapter::zincrby($durationKey, round($event->time), $event->sql); - RedisAdapter::expireat($durationKey, $keyExpiry, 'NX'); + DB::table('pulse_queries')->insert([ + 'date' => now()->subMilliseconds(round($event->time))->toDateTimeString(), + 'user_id' => Auth::id(), + 'sql' => $event->sql, + 'duration' => round($event->time), + ]); - $slowestKey = "pulse_slow_query_slowest_durations:{$keyDate}"; - RedisAdapter::zadd($slowestKey, round($event->time), $event->sql, 'GT'); - RedisAdapter::expireat($slowestKey, $keyExpiry, 'NX'); + // Lottery::odds(1, 100)->winner(fn () => + // DB::table('pulse_queries')->where('date', '<', now()->subDays(7)->toDateTimeString())->delete() + // )->choose(); } } diff --git a/src/Handlers/HandleQueuedJob.php b/src/Handlers/HandleQueuedJob.php new file mode 100644 index 00000000..42b5bb06 --- /dev/null +++ b/src/Handlers/HandleQueuedJob.php @@ -0,0 +1,48 @@ +pulse->doNotReportUsage) { + return; + } + + // TODO: handle the connection + + DB::table('pulse_jobs')->insert([ + 'date' => now()->toDateTimeString(), + 'user_id' => Auth::id(), + 'job' => is_string($event->job) + ? $event->job + : $event->job::class, + 'job_id' => $event->id, + ]); + + // Lottery::odds(1, 100)->winner(fn () => + // DB::table('pulse_jobs')->where('date', '<', now()->subDays(7)->toDateTimeString())->delete() + // )->choose(); + } +} diff --git a/src/Http/Livewire/Concerns/HasPeriod.php b/src/Http/Livewire/Concerns/HasPeriod.php new file mode 100644 index 00000000..0f3e8194 --- /dev/null +++ b/src/Http/Livewire/Concerns/HasPeriod.php @@ -0,0 +1,37 @@ +listeners[] = 'periodChanged'; + + $this->period = (request()->query('period') ?: $this->period) ?: '1_hour'; + } + + /** + * Handle the periodChanged event. + * + * @param '1_hour'|6_hours'|'24_hours'|'7_days' $period + * @return void + */ + public function periodChanged($period) + { + $this->period = $period; + } + +} diff --git a/src/Http/Livewire/Exceptions.php b/src/Http/Livewire/Exceptions.php index d2f4244a..cc3eaabe 100644 --- a/src/Http/Livewire/Exceptions.php +++ b/src/Http/Livewire/Exceptions.php @@ -2,18 +2,110 @@ namespace Laravel\Pulse\Http\Livewire; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Laravel\Pulse\Contracts\ShouldNotReportUsage; -use Laravel\Pulse\Pulse; +use Laravel\Pulse\Http\Livewire\Concerns\HasPeriod; use Livewire\Component; class Exceptions extends Component implements ShouldNotReportUsage { - public string $sortBy = 'count'; + use HasPeriod; - public function render(Pulse $pulse) + /** + * The view type + * + * @var 'count'|'last_occurrence'|null + */ + public $orderBy; + + /** + * The query string parameters. + * + * @var array + */ + protected $queryString = [ + 'orderBy' => ['except' => 'count', 'as' => 'exceptions_by'], + ]; + + /** + * Handle the mount event. + * + * @return void + */ + public function mount() + { + $this->orderBy = $this->orderBy ?: 'count'; + } + + /** + * Render the component. + * + * @return \Illuminate\View\View + */ + public function render() { + if (request()->hasHeader('X-Livewire')) { + $this->loadData(); + } + + [$exceptions, $time, $runAt] = $this->exceptions(); + return view('pulse::livewire.exceptions', [ - 'exceptions' => $pulse->exceptions()->sortByDesc($this->sortBy), + 'time' => $time, + 'runAt' => $runAt, + 'exceptions' => $exceptions, + 'initialDataLoaded' => $exceptions !== null ]); } + + /** + * The exceptions. + * + * @return array + */ + protected function exceptions() + { + return Cache::get("pulse:exceptions:{$this->orderBy}:{$this->period}") ?? [null, 0, null]; + } + + /** + * Load the data for the component. + * + * @return void + */ + public function loadData() + { + Cache::remember("pulse:exceptions:{$this->orderBy}:{$this->period}", now()->addSeconds(match ($this->period) { + '6_hours' => 30, + '24_hours' => 60, + '7_days' => 600, + default => 5, + }), function () { + $now = now()->toImmutable(); + + $start = hrtime(true); + + $exceptions = DB::table('pulse_exceptions') + ->selectRaw('class, location, COUNT(*) AS count, MAX(date) AS last_occurrence') + ->where('date', '>=', $now->subHours(match ($this->period) { + '6_hours' => 6, + '24_hours' => 24, + '7_days' => 168, + default => 1, + })->toDateTimeString()) + ->groupBy('class', 'location') + ->orderByDesc(match ($this->orderBy) { + 'last_occurrence' => 'last_occurrence', + default => 'count' + }) + ->get(); + + $time = (int) ((hrtime(true) - $start) / 1000000); + + return [$exceptions, $time, $now->toDateTimeString()]; + }); + + $this->dispatchBrowserEvent('exceptions:dataLoaded'); + } } diff --git a/src/Http/Livewire/PeriodSelector.php b/src/Http/Livewire/PeriodSelector.php new file mode 100644 index 00000000..00c369f8 --- /dev/null +++ b/src/Http/Livewire/PeriodSelector.php @@ -0,0 +1,41 @@ + ['except' => '1_hour'], + ]; + + /** + * Render the component. + * + * @return \Illuminate\View\View + */ + public function render() + { + return view('pulse::livewire.period-selector'); + } + + public function setPeriod($period) + { + $this->period = $period; + $this->emit('periodChanged', $period); + } +} diff --git a/src/Http/Livewire/Queues.php b/src/Http/Livewire/Queues.php index fcb91af0..ff10aaa9 100644 --- a/src/Http/Livewire/Queues.php +++ b/src/Http/Livewire/Queues.php @@ -2,16 +2,20 @@ namespace Laravel\Pulse\Http\Livewire; +use Illuminate\Support\Facades\Queue; use Laravel\Pulse\Contracts\ShouldNotReportUsage; -use Laravel\Pulse\Pulse; use Livewire\Component; class Queues extends Component implements ShouldNotReportUsage { - public function render(Pulse $pulse) + public function render() { return view('pulse::livewire.queues', [ - 'queues' => $pulse->queues(), + 'queues' => collect(config('pulse.queues'))->map(fn ($queue) => [ + 'queue' => $queue, + 'size' => Queue::size($queue), + 'failed' => collect(app('queue.failer')->all())->filter(fn ($job) => $job->queue === $queue)->count(), + ]) ]); } } diff --git a/src/Http/Livewire/Servers.php b/src/Http/Livewire/Servers.php index 47c04087..07e6e465 100644 --- a/src/Http/Livewire/Servers.php +++ b/src/Http/Livewire/Servers.php @@ -2,26 +2,85 @@ namespace Laravel\Pulse\Http\Livewire; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Laravel\Pulse\Contracts\ShouldNotReportUsage; -use Laravel\Pulse\Pulse; +use Laravel\Pulse\Http\Livewire\Concerns\HasPeriod; use Livewire\Component; class Servers extends Component implements ShouldNotReportUsage { - public function render(Pulse $pulse) + use HasPeriod; + + public function render() { - $servers = $pulse->servers()->toArray(); + $maxDataPoints = 60; + $servers = $this->servers($maxDataPoints); if (request()->hasHeader('X-Livewire')) { $this->emit('chartUpdate', $servers); - - return view('pulse::livewire.servers', [ - 'servers' => $servers, - ]); } return view('pulse::livewire.servers', [ 'servers' => $servers, ]); } + + protected function servers($maxDataPoints) + { + $serverReadings = DB::table('pulse_servers') + ->selectRaw(' + MAX(date) AS date, + server, + ROUND(AVG(cpu_percent)) AS cpu_percent, + ROUND(AVG(memory_used)) AS memory_used + ') + ->orderByDesc('date') + ->when(true, fn ($query) => match ($this->period) { + '7_days' => $query + ->where('date', '>=', now()->subDays(7)) + ->groupByRaw('server, ROUND(UNIX_TIMESTAMP(date) / ?)', [ 604800 / $maxDataPoints ]), + '24_hours' => $query + ->where('date', '>=', now()->subHours(24)) + ->groupByRaw('server, ROUND(UNIX_TIMESTAMP(date) / ?)', [ 86400 / $maxDataPoints ]), + '6_hours' => $query + ->where('date', '>=', now()->subHours(6)) + ->groupByRaw('server, ROUND(UNIX_TIMESTAMP(date) / ?)', [ 21600 / $maxDataPoints ]), + default => $query + ->where('date', '>=', now()->subHour()) + ->groupByRaw('server, ROUND(UNIX_TIMESTAMP(date) / ?)', [ 3600 / $maxDataPoints ]) + }) + ->limit($maxDataPoints) + ->get() + ->reverse() + ->groupBy('server'); + + return DB::table('pulse_servers') + ->joinSub( + DB::table('pulse_servers') + ->selectRaw('server, MAX(date) AS date') + ->groupBy('server'), + 'grouped', + fn ($join) => $join + ->on('pulse_servers.server', '=', 'grouped.server') + ->on('pulse_servers.date', '=', 'grouped.date') + ) + ->get() + ->map(fn ($server) => (object) [ + 'name' => $server->server, + 'slug' => Str::slug($server->server), + 'cpu_percent' => $server->cpu_percent, + 'memory_used' => $server->memory_used, + 'memory_total' => $server->memory_total, + 'storage' => json_decode($server->storage), + 'readings' => $serverReadings[$server->server]?->map(fn ($reading) => (object) [ + 'date' => $reading->date, + 'cpu_percent' => $reading->cpu_percent, + 'memory_used' => $reading->memory_used, + ]) ?? [], + 'updated_at' => Carbon::parse($server->date), + ]) + ->keyBy('slug'); + } } diff --git a/src/Http/Livewire/SlowEndpoints.php b/src/Http/Livewire/SlowEndpoints.php deleted file mode 100644 index a69342d3..00000000 --- a/src/Http/Livewire/SlowEndpoints.php +++ /dev/null @@ -1,17 +0,0 @@ - $pulse->slowEndpoints(), - ]); - } -} diff --git a/src/Http/Livewire/SlowJobs.php b/src/Http/Livewire/SlowJobs.php new file mode 100644 index 00000000..ed06d010 --- /dev/null +++ b/src/Http/Livewire/SlowJobs.php @@ -0,0 +1,86 @@ +hasHeader('X-Livewire')) { + $this->loadData(); + } + + [$slowJobs, $time, $runAt] = $this->slowJobs(); + + return view('pulse::livewire.slow-jobs', [ + 'time' => $time, + 'runAt' => $runAt, + 'slowJobs' => $slowJobs, + 'initialDataLoaded' => $slowJobs !== null, + ]); + } + + /** + * The slow jobs. + * + * @return array + */ + protected function slowJobs() + { + return Cache::get("pulse:slow-jobs:{$this->period}") ?? [null, 0, null]; + } + + /** + * Load the data for the component. + * + * @return void + */ + public function loadData() + { + Cache::remember("pulse:slow-jobs:{$this->period}", now()->addSeconds(match ($this->period) { + '6_hours' => 30, + '24_hours' => 60, + '7_days' => 600, + default => 5, + }), function () { + $now = now()->toImmutable(); + + $start = hrtime(true); + + $slowJobs = DB::table('pulse_jobs') + ->selectRaw('`job`, COUNT(*) as count, MAX(duration) AS slowest') + ->where('date', '>=', $now->subHours(match ($this->period) { + '6_hours' => 6, + '24_hours' => 24, + '7_days' => 168, + default => 1, + })->toDateTimeString()) + ->where('duration', '>=', config('pulse.slow_job_threshold')) + ->groupBy('job') + ->orderByDesc('slowest') + ->get() + ->all(); + + $time = (int) ((hrtime(true) - $start) / 1000000); + + return [$slowJobs, $time, $now->toDateTimeString()]; + }); + + $this->dispatchBrowserEvent('slow-jobs:dataLoaded'); + } +} diff --git a/src/Http/Livewire/SlowQueries.php b/src/Http/Livewire/SlowQueries.php index 442606fe..46183a16 100644 --- a/src/Http/Livewire/SlowQueries.php +++ b/src/Http/Livewire/SlowQueries.php @@ -2,16 +2,83 @@ namespace Laravel\Pulse\Http\Livewire; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Laravel\Pulse\Contracts\ShouldNotReportUsage; -use Laravel\Pulse\Pulse; +use Laravel\Pulse\Http\Livewire\Concerns\HasPeriod; use Livewire\Component; class SlowQueries extends Component implements ShouldNotReportUsage { - public function render(Pulse $pulse) + use HasPeriod; + + /** + * Render the component. + * + * @return \Illuminate\View\View + */ + public function render() { + if (request()->hasHeader('X-Livewire')) { + $this->loadData(); + } + + [$slowQueries, $time, $runAt] = $this->slowQueries(); + return view('pulse::livewire.slow-queries', [ - 'slowQueries' => $pulse->slowQueries(), + 'time' => $time, + 'runAt' => $runAt, + 'slowQueries' => $slowQueries, + 'initialDataLoaded' => $slowQueries !== null, ]); } + + /** + * The slow queries. + * + * @return array + */ + protected function slowQueries() + { + return Cache::get("pulse:slow-queries:{$this->period}") ?? [null, 0, null]; + } + + /** + * Load the data for the component. + * + * @return void + */ + public function loadData() + { + Cache::remember("pulse:slow-queries:{$this->period}", now()->addSeconds(match ($this->period) { + '6_hours' => 30, + '24_hours' => 60, + '7_days' => 600, + default => 5, + }), function () { + $now = now()->toImmutable(); + + $start = hrtime(true); + + $slowQueries = DB::table('pulse_queries') + ->selectRaw('`sql`, COUNT(*) as count, MAX(duration) AS slowest') + ->where('date', '>=', $now->subHours(match ($this->period) { + '6_hours' => 6, + '24_hours' => 24, + '7_days' => 168, + default => 1, + })->toDateTimeString()) + ->where('duration', '>=', config('pulse.slow_query_threshold')) + ->groupBy('sql') + ->orderByDesc('slowest') + ->get() + ->all(); + + $time = (int) ((hrtime(true) - $start) / 1000000); + + return [$slowQueries, $time, $now->toDateTimeString()]; + }); + + $this->dispatchBrowserEvent('slow-queries:dataLoaded'); + } } diff --git a/src/Http/Livewire/SlowRoutes.php b/src/Http/Livewire/SlowRoutes.php new file mode 100644 index 00000000..57d63dff --- /dev/null +++ b/src/Http/Livewire/SlowRoutes.php @@ -0,0 +1,96 @@ +hasHeader('X-Livewire')) { + $this->loadData(); + } + + [$slowRoutes, $time, $runAt] = $this->slowRoutes(); + + return view('pulse::livewire.slow-routes', [ + 'time' => $time, + 'runAt' => $runAt, + 'slowRoutes' => $slowRoutes, + 'initialDataLoaded' => $slowRoutes !== null, + ]); + } + + /** + * The slow routes. + * + * @return array + */ + protected function slowRoutes() + { + return Cache::get("pulse:slow-routes:{$this->period}") ?? [null, 0, null]; + } + + /** + * Load the data for the component. + * + * @return void + */ + public function loadData() + { + Cache::remember("pulse:slow-routes:{$this->period}", now()->addSeconds(match ($this->period) { + '6_hours' => 30, + '24_hours' => 60, + '7_days' => 600, + default => 5, + }), function () { + $now = now()->toImmutable(); + + $start = hrtime(true); + + $slowRoutes = DB::table('pulse_requests') + ->selectRaw('route, COUNT(*) as count, MAX(duration) AS slowest') + ->where('date', '>=', $now->subHours(match ($this->period) { + '6_hours' => 6, + '24_hours' => 24, + '7_days' => 168, + default => 1, + })->toDateTimeString()) + ->where('duration', '>=', config('pulse.slow_endpoint_threshold')) + ->groupBy('route') + ->orderByDesc('slowest') + ->get() + ->map(function ($row) { + [$method, $path] = explode(' ', $row->route, 2); + $route = Route::getRoutes()->get($method)[$path] ?? null; + + return [ + 'uri' => $row->route, + 'action' => $route?->getActionName(), + 'request_count' => (int) $row->count, + 'slowest_duration' => (int) $row->slowest, + ]; + }) + ->all(); + + $time = (int) ((hrtime(true) - $start) / 1000000); + + return [$slowRoutes, $time, $now->toDateTimeString()]; + }); + + $this->dispatchBrowserEvent('slow-routes:dataLoaded'); + } +} diff --git a/src/Http/Livewire/Usage.php b/src/Http/Livewire/Usage.php index fc07e910..6fc95957 100644 --- a/src/Http/Livewire/Usage.php +++ b/src/Http/Livewire/Usage.php @@ -2,19 +2,132 @@ namespace Laravel\Pulse\Http\Livewire; +use Illuminate\Foundation\Auth\User; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Laravel\Pulse\Contracts\ShouldNotReportUsage; -use Laravel\Pulse\Pulse; +use Laravel\Pulse\Http\Livewire\Concerns\HasPeriod; use Livewire\Component; class Usage extends Component implements ShouldNotReportUsage { - public string $view = 'request-counts'; + use HasPeriod; - public function render(Pulse $pulse) + /** + * The usage type. + * + * @var 'request_counts'|'slow_endpoint_counts'|'dispatched_job_count'|null + */ + public $usage; + + /** + * The query string parameters. + * + * @var array + */ + protected $queryString = [ + 'usage' => ['except' => 'request_counts'], + ]; + + /** + * Handle the mount event. + * + * @return void + */ + public function mount() + { + $this->usage = $this->usage ?: 'request_counts'; + } + + /** + * Render the component. + * + * @return \Illuminate\View\View + */ + public function render() { + if (request()->hasHeader('X-Livewire')) { + $this->loadData(); + } + + [$userRequestCounts, $time, $runAt] = $this->userRequestCounts(); + return view('pulse::livewire.usage', [ - 'userRequestCounts' => $pulse->userRequestCounts(), - 'usersExperiencingSlowEndpoints' => $pulse->usersExperiencingSlowEndpoints(), + 'time' => $time, + 'runAt' => $runAt, + 'userRequestCounts' => $userRequestCounts, + 'initialDataLoaded' => $userRequestCounts !== null, ]); } + + /** + * The user request counts. + * + * @return array + */ + protected function userRequestCounts() + { + return Cache::get("pulse:usage:{$this->usage}:{$this->period}") ?? [null, 0, null]; + } + + /** + * Load the data for the component. + * + * @return void + */ + public function loadData() + { + Cache::remember("pulse:usage:{$this->usage}:{$this->period}", now()->addSeconds(match ($this->period) { + '6_hours' => 30, + '24_hours' => 60, + '7_days' => 600, + default => 5, + }), function () { + $now = now()->toImmutable(); + + $start = hrtime(true); + + $top10 = DB::query() + ->when($this->usage === 'dispatched_job_count', function ($query) { + $query->from('pulse_jobs'); + }, function ($query) { + $query->from('pulse_requests'); + }) + ->selectRaw('user_id, COUNT(*) as count') + ->whereNotNull('user_id') + ->where('date', '>=', $now->subHours(match ($this->period) { + '6_hours' => 6, + '24_hours' => 24, + '7_days' => 168, + default => 1, + })->toDateTimeString()) + ->when($this->usage === 'slow_endpoint_counts', fn ($query) => $query->where('duration', '>=', config('pulse.slow_endpoint_threshold'))) + ->groupBy('user_id') + ->orderByDesc('count') + ->limit(10) + ->get(); + + // TODO: extract to user customisable resolver. + $users = User::findMany($top10->pluck('user_id')); + + $userRequestCounts = $top10 + ->map(function ($row) use ($users) { + $user = $users->firstWhere('id', $row->user_id); + + return $user ? [ + 'count' => $row->count, + 'user' => $user->setVisible(['name', 'email']), + ] : null; + }) + ->filter() + ->values() + ->all(); + + $time = (int) ((hrtime(true) - $start) / 1000000); + + return [$userRequestCounts, $time, $now->toDateTimeString()]; + }); + + $this->dispatchBrowserEvent('usage:dataLoaded'); + } } diff --git a/src/Pulse.php b/src/Pulse.php index ee8245bb..85b4769c 100644 --- a/src/Pulse.php +++ b/src/Pulse.php @@ -2,188 +2,16 @@ namespace Laravel\Pulse; -use Illuminate\Foundation\Auth\User; -use Illuminate\Support\Facades\Queue; -use Illuminate\Support\Facades\Route; -use Illuminate\Support\Str; - class Pulse { - public bool $doNotReportUsage = false; - - public function servers() - { - // TODO: Exclude servers that haven't reported recently? - return collect(RedisAdapter::hgetall('pulse_servers')) - ->map(function ($name, $slug) { - $readings = collect(RedisAdapter::xrange("pulse_servers:{$slug}", '-', '+')) - ->map(fn ($server) => [ - 'timestamp' => (int) $server['timestamp'], - 'cpu' => (int) $server['cpu'], - 'memory_used' => (int) $server['memory_used'], - 'memory_total' => (int) $server['memory_total'], - 'storage' => json_decode($server['storage']), - ]) - ->values(); - - if ($readings->isEmpty()) { - return null; - } - - return [ - 'name' => $name, - 'readings' => $readings, - ]; - }) - ->filter(); - } - - public function userRequestCounts() - { - // TODO: We probably don't need to rebuild this on every request - maybe once per hour? - RedisAdapter::zunionstore( - 'pulse_user_request_counts:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_user_request_counts:'.now()->subDays($days)->format('Y-m-d')) - ->toArray() - ); - - $scores = collect(RedisAdapter::zrevrange('pulse_user_request_counts:7-day', 0, 9, true)); - - $users = User::findMany($scores->keys()); - - return collect($scores) - ->map(function ($score, $userId) use ($users) { - $user = $users->firstWhere('id', $userId); - - return $user ? [ - 'count' => $score, - // TODO: Make configurable - 'user' => $user->setVisible(['name', 'email']), - ] : null; - }) - ->filter() - ->values(); - } - - public function slowEndpoints() - { - // TODO: Do we want to rebuild this on every request? - RedisAdapter::zunionstore( - 'pulse_slow_endpoint_request_counts:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_endpoint_request_counts:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - RedisAdapter::zunionstore( - 'pulse_slow_endpoint_total_durations:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_endpoint_total_durations:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - RedisAdapter::zunionstore( - 'pulse_slow_endpoint_slowest_durations:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_endpoint_slowest_durations:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'MAX' - ); - - $requestCounts = RedisAdapter::zrevrange('pulse_slow_endpoint_request_counts:7-day', 0, -1, true); - $totalDurations = RedisAdapter::zrevrange('pulse_slow_endpoint_total_durations:7-day', 0, -1, true); - $slowestDurations = RedisAdapter::zrevrange('pulse_slow_endpoint_slowest_durations:7-day', 0, -1, true); - - return collect($requestCounts) - ->map(function ($requestCount, $uri) use ($totalDurations, $slowestDurations) { - $method = substr($uri, 0, strpos($uri, ' ')); - $path = substr($uri, strpos($uri, ' ') + 1); - $route = Route::getRoutes()->get($method)[$path] ?? null; - - return [ - 'uri' => $method.' '.Str::start($path, '/'), - 'action' => $route?->getActionName(), - 'request_count' => (int) $requestCount, - 'slowest_duration' => isset($slowestDurations[$uri]) ? (int) $slowestDurations[$uri] : null, - 'average_duration' => isset($totalDurations[$uri]) ? (int) round($totalDurations[$uri] / $requestCount) : null, - ]; - }) - ->values(); - } - - public function usersExperiencingSlowEndpoints() - { - RedisAdapter::zunionstore( - 'pulse_slow_endpoint_user_counts:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_endpoint_user_counts:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - $userCounts = collect(RedisAdapter::zrevrange('pulse_slow_endpoint_user_counts:7-day', 0, -1, true)); + /** + * Indicates if Pulse migrations will be run. + * + * @var bool + */ + public static $runsMigrations = true; - // TODO: polling for this every 2 seconds is probably not great. - $users = User::findMany($userCounts->keys()); - - return $userCounts - ->map(function ($count, $userId) use ($users) { - $user = $users->firstWhere('id', $userId); - - return $user ? [ - 'count' => $count, - 'user' => $user->setVisible(['name', 'email']), - ] : null; - }) - ->filter() - ->values(); - } - - public function slowQueries() - { - // TODO: Do we want to rebuild this on every request? - RedisAdapter::zunionstore( - 'pulse_slow_query_execution_counts:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_query_execution_counts:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - RedisAdapter::zunionstore( - 'pulse_slow_query_total_durations:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_query_total_durations:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - RedisAdapter::zunionstore( - 'pulse_slow_query_slowest_durations:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_slow_query_slowest_durations:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'MAX' - ); - - $executionCounts = RedisAdapter::zrevrange('pulse_slow_query_execution_counts:7-day', 0, -1, true); - $totalDurations = RedisAdapter::zrevrange('pulse_slow_query_total_durations:7-day', 0, -1, true); - $slowestDurations = RedisAdapter::zrevrange('pulse_slow_query_slowest_durations:7-day', 0, -1, true); - - return collect($executionCounts) - ->map(function ($executionCount, $sql) use ($totalDurations, $slowestDurations) { - return [ - 'sql' => $sql, - 'execution_count' => (int) $executionCount, - 'slowest_duration' => isset($slowestDurations[$sql]) ? (int) $slowestDurations[$sql] : null, - 'average_duration' => isset($totalDurations[$sql]) ? (int) round($totalDurations[$sql] / $executionCount) : null, - ]; - }) - ->values(); - } + public bool $doNotReportUsage = false; public function cacheStats() { @@ -210,45 +38,6 @@ public function cacheStats() ]; } - public function exceptions() - { - RedisAdapter::zunionstore( - 'pulse_exception_counts:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_exception_counts:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'SUM' - ); - - RedisAdapter::zunionstore( - 'pulse_exception_last_occurrences:7-day', - collect(range(0, 6)) - ->map(fn ($days) => 'pulse_exception_last_occurrences:'.now()->subDays($days)->format('Y-m-d')) - ->toArray(), - 'MAX' - ); - - $exceptionCounts = RedisAdapter::zrevrange('pulse_exception_counts:7-day', 0, -1, true); - $exceptionLastOccurrences = RedisAdapter::zrevrange('pulse_exception_last_occurrences:7-day', 0, -1, true); - - return collect($exceptionCounts) - ->map(fn ($count, $exception) => [ - ...json_decode($exception, true), - 'count' => $count, - 'last_occurrence' => isset($exceptionLastOccurrences[$exception]) ? (int) $exceptionLastOccurrences[$exception] : null, - ]) - ->values(); - } - - public function queues() - { - return collect(config('pulse.queues'))->map(fn ($queue) => [ - 'queue' => $queue, - 'size' => Queue::size($queue), - 'failed' => collect(app('queue.failer')->all())->filter(fn ($job) => $job->queue === $queue)->count(), - ]); - } - public function css() { return file_get_contents(__DIR__.'/../dist/pulse.css'); @@ -258,4 +47,16 @@ public function js() { return file_get_contents(__DIR__.'/../dist/pulse.js'); } + + /** + * Configure Pulse to not register its migrations. + * + * @return static + */ + public static function ignoreMigrations() + { + static::$runsMigrations = false; + + return new static; + } } diff --git a/src/PulseServiceProvider.php b/src/PulseServiceProvider.php index 9b1321fc..b4be8cb3 100644 --- a/src/PulseServiceProvider.php +++ b/src/PulseServiceProvider.php @@ -7,24 +7,33 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Http\Kernel; use Illuminate\Log\Events\MessageLogged; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Queue\Events\JobQueued; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Laravel\Pulse\Commands\CheckCommand; +use Laravel\Pulse\Commands\WorkCommand; use Laravel\Pulse\Contracts\ShouldNotReportUsage; use Laravel\Pulse\Handlers\HandleCacheHit; use Laravel\Pulse\Handlers\HandleCacheMiss; use Laravel\Pulse\Handlers\HandleException; use Laravel\Pulse\Handlers\HandleHttpRequest; use Laravel\Pulse\Handlers\HandleLogMessage; +use Laravel\Pulse\Handlers\HandleProcessedJob; +use Laravel\Pulse\Handlers\HandleProcessingJob; use Laravel\Pulse\Handlers\HandleQuery; +use Laravel\Pulse\Handlers\HandleQueuedJob; use Laravel\Pulse\Http\Livewire\Cache; use Laravel\Pulse\Http\Livewire\Exceptions; +use Laravel\Pulse\Http\Livewire\PeriodSelector; use Laravel\Pulse\Http\Livewire\Queues; use Laravel\Pulse\Http\Livewire\Servers; -use Laravel\Pulse\Http\Livewire\SlowEndpoints; +use Laravel\Pulse\Http\Livewire\SlowJobs; +use Laravel\Pulse\Http\Livewire\SlowRoutes; use Laravel\Pulse\Http\Livewire\SlowQueries; use Laravel\Pulse\Http\Livewire\Usage; use Laravel\Pulse\View\Components\Pulse as PulseComponent; @@ -40,12 +49,15 @@ class PulseServiceProvider extends ServiceProvider */ public function register() { - if ($this->app->runningUnitTests()) { - return; - } + // TODO: will need to restore this one. Probably with a static. + // if ($this->app->runningUnitTests()) { + // return; + // } $this->app->singleton(Pulse::class); + $this->app->singleton(Redis::class, fn ($app) => new Redis($app['redis']->connection()->client())); + $this->mergeConfigFrom( __DIR__.'/../config/pulse.php', 'pulse' ); @@ -61,20 +73,23 @@ public function register() protected function listenForEvents() { $this->app->make(Kernel::class) - ->whenRequestLifecycleIsLongerThan(0, function ($startedAt, $request, $response) { - (new HandleHttpRequest)($startedAt, $request, $response); - }); + ->whenRequestLifecycleIsLongerThan(0, fn (...$args) => app(HandleHttpRequest::class)(...$args)); - DB::listen(fn ($e) => (new HandleQuery)($e)); + DB::listen(fn ($e) => app(HandleQuery::class)($e)); $this->app->make(ExceptionHandler::class) ->reportable(function (Throwable $e) { - (new HandleException)($e); + app(HandleException::class)($e); }); //Event::listen(MessageLogged::class, HandleLogMessage::class); Event::listen(CacheHit::class, HandleCacheHit::class); Event::listen(CacheMissed::class, HandleCacheMiss::class); + + // TODO: handle other job events, such as failing. + Event::listen(JobQueued::class, HandleQueuedJob::class); + Event::listen(JobProcessing::class, HandleProcessingJob::class); + Event::listen(JobProcessed::class, HandleProcessedJob::class); } /** @@ -129,9 +144,9 @@ protected function registerResources() */ protected function registerMigrations() { - // if ($this->app->runningInConsole() && Pulse::$runsMigrations) { - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); - // } + if ($this->app->runningInConsole() && Pulse::$runsMigrations) { + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + } } /** @@ -170,6 +185,7 @@ protected function registerCommands() if ($this->app->runningInConsole()) { $this->commands([ CheckCommand::class, + WorkCommand::class, ]); } } @@ -183,11 +199,13 @@ protected function registerComponents() { Blade::component('pulse', PulseComponent::class); + Livewire::component('period-selector', PeriodSelector::class); Livewire::component('servers', Servers::class); Livewire::component('usage', Usage::class); Livewire::component('exceptions', Exceptions::class); - Livewire::component('slow-endpoints', SlowEndpoints::class); + Livewire::component('slow-routes', SlowRoutes::class); Livewire::component('slow-queries', SlowQueries::class); + Livewire::component('slow-jobs', SlowJobs::class); Livewire::component('cache', Cache::class); Livewire::component('queues', Queues::class); } diff --git a/src/Redis.php b/src/Redis.php new file mode 100644 index 00000000..78fa7f46 --- /dev/null +++ b/src/Redis.php @@ -0,0 +1,143 @@ +isPhpRedis()) { + return $this->client->rawCommand('EXPIREAT', $prefix.$key, $timestamp, $options); + } + + return $this->client->expireat($key, $timestamp, $options); + } + + public function xadd($key, $dictionary) + { + if ($this->isPhpRedis()) { + return $this->client->xAdd($key, '*', $dictionary); + } + + return $this->client->xAdd($key, $dictionary); + } + + public function xrange($key, $start, $end, $count = null) + { + if ($count) { + return $this->client->xrange($key, $start, $end, $count); + } + + return $this->client->xrange($key, $start, $end); + } + + public function xrevrange($key, $end, $start, $count = null) + { + if ($count) { + return $this->client->xrevrange($key, $end, $start, $count); + } + + return $this->client->xrevrange($key, $end, $start); + } + + public function xtrim($key, $strategy, $threshold) + { + $prefix = config('database.redis.options.prefix'); + + if ($this->isPhpRedis()) { + // PHP Redis does not support the minid strategy. + return $this->client->rawCommand('XTRIM', $prefix.$key, $strategy, $threshold); + } + + return $this->client->xtrim($key, $strategy, $threshold); + } + + public function zadd($key, $score, $member, $options = null) + { + $prefix = config('database.redis.options.prefix'); + + return match (true) { + $this->isPhpRedis() && $options === null => $this->client->zAdd($key, $score, $member), + $this->isPhpRedis() && $options !== null => $this->client->rawCommand('ZADD', $prefix.$key, $options, $score, $member), + $this->isPredis() && $options === null => $this->client->zadd($key, [$member => $score]), + $this->isPredis() && $options !== null => $this->client->executeRaw(['ZADD', $prefix.$key, $options, $score, $member]), + }; + } + + public function zunionstore($destination, $keys, $aggregate = 'SUM') + { + if ($this->isPhpRedis()) { + return $this->client->zUnionStore($destination, $keys, ['aggregate' => strtoupper($aggregate)]); + } + + return $this->client->zunionstore($destination, $keys, [], strtolower($aggregate)); + } + + /** + * Retrieve the time of the Redis server. + */ + public function now(): CarbonImmutable + { + return CarbonImmutable::createFromTimestamp($this->time()[0], 'UTC'); + } + + /** + * Retrieve the oldest entry date for the given stream. + */ + public function oldestStreamEntryDate(string $stream): ?CarbonImmutable + { + $key = array_key_first($this->xrange($stream, '-', '+', 1)); + + if ($key === null) { + return null; + } + + return CarbonImmutable::createFromTimestampMs(Str::before($key, '-'), 'UTC')->startOfSecond(); + } + + /** + * Determine if the client is PhpRedis. + */ + protected function isPhpRedis(): bool + { + return $this->client instanceof PhpRedis; + } + + /** + * Determine if the client is Predis. + */ + protected function isPredis(): bool + { + return $this->client instanceof Predis; + } + + /** + * Proxies all method calls to the client. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->client->{$method}(...$parameters); + } +} + diff --git a/src/RedisAdapter.php b/src/RedisAdapter.php index f4c20804..54bbed8e 100644 --- a/src/RedisAdapter.php +++ b/src/RedisAdapter.php @@ -36,6 +36,11 @@ public static function incr($key) return Redis::incr($key); } + public static function time() + { + return Redis::time(); + } + public static function xadd($key, $dictionary) { return match (true) { @@ -44,19 +49,31 @@ public static function xadd($key, $dictionary) }; } - public static function xrange($key, $start, $end) + public static function xrange($key, $start, $end, $count = null) { + if ($count) { + return Redis::xrange($key, $start, $end, $count); + } + return Redis::xrange($key, $start, $end); } - public static function xtrim($key, $threshold) + public static function xrevrange($key, $end, $start, $count = null) + { + if ($count) { + return Redis::xrevrange($key, $end, $start, $count); + } + + return Redis::xrevrange($key, $end, $start); + } + + public static function xtrim($key, $strategy, $threshold) { $prefix = config('database.redis.options.prefix'); return match (true) { - Redis::client() instanceof \Redis => Redis::xTrim($key, $threshold), - // Predis currently doesn't apply the prefix on XTRIM commands. - Redis::client() instanceof \Predis\Client => Redis::xtrim($prefix.$key, 'MAXLEN', $threshold), + Redis::client() instanceof \Redis => Redis::rawCommand('XTRIM', $prefix.$key, $strategy, $threshold), + Redis::client() instanceof \Predis\Client => Redis::xtrim($key, 'MAXLEN', $threshold), }; } diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 61cd84c3..00000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/WorkCommandTest.php b/tests/Feature/WorkCommandTest.php new file mode 100644 index 00000000..968cd417 --- /dev/null +++ b/tests/Feature/WorkCommandTest.php @@ -0,0 +1,11 @@ + $startedAt->diffInMilliseconds(now()), + 'route' => 'GET /users' + 'user_id' => 5, + ]); + + expect(true)->toBeTrue(); +});