rails-profiler 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler.css +293 -0
- data/app/assets/builds/profiler.js +369 -18
- data/app/controllers/profiler/api/explain_controller.rb +31 -0
- data/config/routes.rb +1 -0
- data/lib/profiler/explain_runner.rb +82 -0
- data/lib/profiler/mcp/resources/n1_patterns.rb +7 -6
- data/lib/profiler/mcp/server.rb +13 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +14 -9
- data/lib/profiler/mcp/tools/explain_query.rb +50 -0
- data/lib/profiler/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c1f16fad7d5792dda0491401519802bd402be3b6c952b898cc0930e675a0d49
|
|
4
|
+
data.tar.gz: 6ea6e3ca98b297f89b32da94a56cf73ff38788e8d31a9570dd58c072bfb58930
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e487bc2fc57bd9a1dee6eabdc45db629dd752e0df2ce95e74fc31701583ab5d8de72fa57b7b19dc81d6c412c6bec2b4a814768af84ab80da5af6c61b3fb063a
|
|
7
|
+
data.tar.gz: 0e3572af0b7f7bca2d1bc132da45173ad9dd2f29010d5685a987d247a89c114d5c76846781a5383aa7bee2fdef038059aa66ac8c9e8bf7ed1245c8a6eaf50354
|
|
@@ -867,6 +867,217 @@ tr:hover .btn-row-delete {
|
|
|
867
867
|
line-height: 1.5;
|
|
868
868
|
}
|
|
869
869
|
|
|
870
|
+
.profiler-query-card--n1 {
|
|
871
|
+
border-left: 3px solid var(--profiler-warning);
|
|
872
|
+
background: var(--profiler-warning-bg);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.profiler-n1-group {
|
|
876
|
+
border: 1px solid var(--profiler-warning);
|
|
877
|
+
border-radius: var(--profiler-radius-md);
|
|
878
|
+
overflow: hidden;
|
|
879
|
+
}
|
|
880
|
+
.profiler-n1-group__header {
|
|
881
|
+
display: flex;
|
|
882
|
+
align-items: center;
|
|
883
|
+
gap: var(--profiler-space-3);
|
|
884
|
+
padding: var(--profiler-space-2) var(--profiler-space-3);
|
|
885
|
+
background: var(--profiler-warning-bg);
|
|
886
|
+
cursor: pointer;
|
|
887
|
+
user-select: none;
|
|
888
|
+
}
|
|
889
|
+
.profiler-n1-group__header:hover {
|
|
890
|
+
filter: brightness(0.97);
|
|
891
|
+
}
|
|
892
|
+
.profiler-n1-group__count {
|
|
893
|
+
font-size: var(--profiler-text-xs);
|
|
894
|
+
font-weight: 700;
|
|
895
|
+
font-family: var(--profiler-font-mono);
|
|
896
|
+
color: var(--profiler-warning);
|
|
897
|
+
white-space: nowrap;
|
|
898
|
+
}
|
|
899
|
+
.profiler-n1-group__pattern {
|
|
900
|
+
flex: 1;
|
|
901
|
+
font-size: var(--profiler-text-xs);
|
|
902
|
+
font-family: var(--profiler-font-mono);
|
|
903
|
+
color: var(--profiler-text);
|
|
904
|
+
overflow: hidden;
|
|
905
|
+
text-overflow: ellipsis;
|
|
906
|
+
white-space: nowrap;
|
|
907
|
+
}
|
|
908
|
+
.profiler-n1-group__toggle {
|
|
909
|
+
font-size: var(--profiler-text-xs);
|
|
910
|
+
color: var(--profiler-text-muted);
|
|
911
|
+
white-space: nowrap;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.profiler-n1-backtrace {
|
|
915
|
+
background: var(--profiler-bg-lighter);
|
|
916
|
+
border-top: 1px solid var(--profiler-border);
|
|
917
|
+
padding: var(--profiler-space-2) var(--profiler-space-3);
|
|
918
|
+
}
|
|
919
|
+
.profiler-n1-backtrace__frame {
|
|
920
|
+
font-size: var(--profiler-text-xs);
|
|
921
|
+
font-family: var(--profiler-font-mono);
|
|
922
|
+
color: var(--profiler-text-muted);
|
|
923
|
+
line-height: 1.8;
|
|
924
|
+
overflow: hidden;
|
|
925
|
+
text-overflow: ellipsis;
|
|
926
|
+
white-space: nowrap;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
.profiler-alert-banner {
|
|
930
|
+
display: flex;
|
|
931
|
+
align-items: flex-start;
|
|
932
|
+
gap: var(--profiler-space-3);
|
|
933
|
+
padding: var(--profiler-space-3) var(--profiler-space-4);
|
|
934
|
+
border-radius: var(--profiler-radius-md);
|
|
935
|
+
border: 1px solid;
|
|
936
|
+
font-size: var(--profiler-text-sm);
|
|
937
|
+
}
|
|
938
|
+
.profiler-alert-banner--warning {
|
|
939
|
+
background: var(--profiler-warning-bg);
|
|
940
|
+
border-color: var(--profiler-warning);
|
|
941
|
+
color: var(--profiler-text);
|
|
942
|
+
}
|
|
943
|
+
.profiler-alert-banner--error {
|
|
944
|
+
background: var(--profiler-error-bg);
|
|
945
|
+
border-color: var(--profiler-error);
|
|
946
|
+
}
|
|
947
|
+
.profiler-alert-banner__icon {
|
|
948
|
+
flex-shrink: 0;
|
|
949
|
+
font-size: var(--profiler-text-base);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.profiler-badge {
|
|
953
|
+
display: inline-block;
|
|
954
|
+
padding: 1px 6px;
|
|
955
|
+
border-radius: var(--profiler-radius-sm);
|
|
956
|
+
font-size: var(--profiler-text-xs);
|
|
957
|
+
font-weight: 600;
|
|
958
|
+
font-family: var(--profiler-font-mono);
|
|
959
|
+
line-height: 1.6;
|
|
960
|
+
}
|
|
961
|
+
.profiler-badge--warning {
|
|
962
|
+
background: var(--profiler-warning-bg);
|
|
963
|
+
color: var(--profiler-warning);
|
|
964
|
+
border: 1px solid rgba(251, 146, 60, 0.3);
|
|
965
|
+
}
|
|
966
|
+
.profiler-badge--error {
|
|
967
|
+
background: var(--profiler-error-bg);
|
|
968
|
+
color: var(--profiler-error);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.profiler-btn {
|
|
972
|
+
display: inline-flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
gap: var(--profiler-space-1);
|
|
975
|
+
border: 1px solid var(--profiler-border);
|
|
976
|
+
border-radius: var(--profiler-radius-sm);
|
|
977
|
+
background: var(--profiler-bg-elevated);
|
|
978
|
+
color: var(--profiler-text-muted);
|
|
979
|
+
font-size: var(--profiler-text-sm);
|
|
980
|
+
font-family: var(--profiler-font-mono);
|
|
981
|
+
cursor: pointer;
|
|
982
|
+
transition: all var(--profiler-transition-base);
|
|
983
|
+
}
|
|
984
|
+
.profiler-btn:hover {
|
|
985
|
+
border-color: var(--profiler-accent);
|
|
986
|
+
color: var(--profiler-accent);
|
|
987
|
+
}
|
|
988
|
+
.profiler-btn--sm {
|
|
989
|
+
padding: 1px 8px;
|
|
990
|
+
font-size: var(--profiler-text-xs);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.profiler-modal__overlay {
|
|
994
|
+
position: fixed;
|
|
995
|
+
inset: 0;
|
|
996
|
+
background: rgba(0, 0, 0, 0.6);
|
|
997
|
+
z-index: 9000;
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
justify-content: center;
|
|
1001
|
+
padding: var(--profiler-space-4);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.profiler-modal {
|
|
1005
|
+
background: var(--profiler-bg-elevated);
|
|
1006
|
+
border: 1px solid var(--profiler-border);
|
|
1007
|
+
border-radius: var(--profiler-radius-lg, var(--profiler-radius-md));
|
|
1008
|
+
box-shadow: var(--profiler-shadow-lg, var(--profiler-shadow-sm));
|
|
1009
|
+
width: 100%;
|
|
1010
|
+
max-width: 760px;
|
|
1011
|
+
max-height: 80vh;
|
|
1012
|
+
display: flex;
|
|
1013
|
+
flex-direction: column;
|
|
1014
|
+
overflow: hidden;
|
|
1015
|
+
}
|
|
1016
|
+
.profiler-modal__header {
|
|
1017
|
+
display: flex;
|
|
1018
|
+
align-items: center;
|
|
1019
|
+
gap: var(--profiler-space-3);
|
|
1020
|
+
padding: var(--profiler-space-3) var(--profiler-space-4);
|
|
1021
|
+
border-bottom: 1px solid var(--profiler-border);
|
|
1022
|
+
background: var(--profiler-bg-light);
|
|
1023
|
+
flex-shrink: 0;
|
|
1024
|
+
}
|
|
1025
|
+
.profiler-modal__title {
|
|
1026
|
+
font-weight: 600;
|
|
1027
|
+
font-family: var(--profiler-font-mono);
|
|
1028
|
+
font-size: var(--profiler-text-sm);
|
|
1029
|
+
flex: 1;
|
|
1030
|
+
}
|
|
1031
|
+
.profiler-modal__close {
|
|
1032
|
+
background: none;
|
|
1033
|
+
border: none;
|
|
1034
|
+
color: var(--profiler-text-muted);
|
|
1035
|
+
font-size: var(--profiler-text-lg);
|
|
1036
|
+
cursor: pointer;
|
|
1037
|
+
padding: 0 var(--profiler-space-1);
|
|
1038
|
+
line-height: 1;
|
|
1039
|
+
}
|
|
1040
|
+
.profiler-modal__close:hover {
|
|
1041
|
+
color: var(--profiler-text);
|
|
1042
|
+
}
|
|
1043
|
+
.profiler-modal__body {
|
|
1044
|
+
padding: var(--profiler-space-4);
|
|
1045
|
+
overflow-y: auto;
|
|
1046
|
+
flex: 1;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.profiler-explain-result {
|
|
1050
|
+
font-family: var(--profiler-font-mono);
|
|
1051
|
+
font-size: var(--profiler-text-xs);
|
|
1052
|
+
color: var(--profiler-text);
|
|
1053
|
+
white-space: pre-wrap;
|
|
1054
|
+
word-break: break-all;
|
|
1055
|
+
line-height: 1.6;
|
|
1056
|
+
margin: 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.profiler-explain-node {
|
|
1060
|
+
padding: var(--profiler-space-1) var(--profiler-space-2);
|
|
1061
|
+
margin-bottom: 2px;
|
|
1062
|
+
border-radius: var(--profiler-radius-sm);
|
|
1063
|
+
font-family: var(--profiler-font-mono);
|
|
1064
|
+
font-size: var(--profiler-text-xs);
|
|
1065
|
+
}
|
|
1066
|
+
.profiler-explain-node--expensive {
|
|
1067
|
+
background: var(--profiler-error-bg);
|
|
1068
|
+
border-left: 2px solid var(--profiler-error);
|
|
1069
|
+
}
|
|
1070
|
+
.profiler-explain-node__type {
|
|
1071
|
+
font-weight: 600;
|
|
1072
|
+
color: var(--profiler-accent);
|
|
1073
|
+
}
|
|
1074
|
+
.profiler-explain-node__stats {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
gap: var(--profiler-space-3);
|
|
1077
|
+
margin-top: 1px;
|
|
1078
|
+
flex-wrap: wrap;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
870
1081
|
.profiler-dump-card {
|
|
871
1082
|
background: var(--profiler-bg-light);
|
|
872
1083
|
border: 1px solid var(--profiler-border);
|
|
@@ -1685,6 +1896,61 @@ a.profiler-toolbar-item.profiler-text--warning::after {
|
|
|
1685
1896
|
cursor: pointer;
|
|
1686
1897
|
}
|
|
1687
1898
|
|
|
1899
|
+
.profiler-preset-btn {
|
|
1900
|
+
height: 28px;
|
|
1901
|
+
padding: 0 10px;
|
|
1902
|
+
background: transparent;
|
|
1903
|
+
border: 1px solid var(--profiler-border);
|
|
1904
|
+
border-radius: var(--profiler-radius-full);
|
|
1905
|
+
color: var(--profiler-text-muted);
|
|
1906
|
+
font-family: var(--profiler-font-sans);
|
|
1907
|
+
font-size: var(--profiler-text-xs);
|
|
1908
|
+
font-weight: 500;
|
|
1909
|
+
cursor: pointer;
|
|
1910
|
+
transition: all var(--profiler-transition-base);
|
|
1911
|
+
white-space: nowrap;
|
|
1912
|
+
}
|
|
1913
|
+
.profiler-preset-btn:hover {
|
|
1914
|
+
border-color: var(--profiler-accent);
|
|
1915
|
+
color: var(--profiler-accent);
|
|
1916
|
+
background: var(--profiler-accent-bg);
|
|
1917
|
+
}
|
|
1918
|
+
.profiler-preset-btn--active {
|
|
1919
|
+
border-color: var(--profiler-accent);
|
|
1920
|
+
background: var(--profiler-accent);
|
|
1921
|
+
color: var(--profiler-text-on-accent, #fff);
|
|
1922
|
+
}
|
|
1923
|
+
.profiler-preset-btn--active:hover {
|
|
1924
|
+
opacity: 0.85;
|
|
1925
|
+
color: var(--profiler-text-on-accent, #fff);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.sortable {
|
|
1929
|
+
cursor: pointer;
|
|
1930
|
+
user-select: none;
|
|
1931
|
+
white-space: nowrap;
|
|
1932
|
+
transition: color var(--profiler-transition-base);
|
|
1933
|
+
}
|
|
1934
|
+
.sortable:hover {
|
|
1935
|
+
color: var(--profiler-text) !important;
|
|
1936
|
+
}
|
|
1937
|
+
.sortable--active {
|
|
1938
|
+
color: var(--profiler-text) !important;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
.sort-icon {
|
|
1942
|
+
font-size: 10px;
|
|
1943
|
+
margin-left: 4px;
|
|
1944
|
+
vertical-align: middle;
|
|
1945
|
+
}
|
|
1946
|
+
.sort-icon--idle {
|
|
1947
|
+
opacity: 0.35;
|
|
1948
|
+
}
|
|
1949
|
+
.sort-icon--active {
|
|
1950
|
+
color: var(--profiler-accent);
|
|
1951
|
+
opacity: 1;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1688
1954
|
.profiler-timeline {
|
|
1689
1955
|
margin: 24px 0;
|
|
1690
1956
|
padding: 24px;
|
|
@@ -2110,6 +2376,33 @@ a.profiler-toolbar-item.profiler-text--warning::after {
|
|
|
2110
2376
|
color: var(--profiler-text-subtle);
|
|
2111
2377
|
font-family: var(--profiler-font-sans);
|
|
2112
2378
|
}
|
|
2379
|
+
.profiler-flamegraph__search {
|
|
2380
|
+
display: flex;
|
|
2381
|
+
align-items: center;
|
|
2382
|
+
gap: 8px;
|
|
2383
|
+
}
|
|
2384
|
+
.profiler-flamegraph__search-input {
|
|
2385
|
+
height: 28px;
|
|
2386
|
+
padding: 0 8px;
|
|
2387
|
+
background: var(--profiler-bg-lighter);
|
|
2388
|
+
border: 1px solid var(--profiler-border);
|
|
2389
|
+
border-radius: var(--profiler-radius-sm);
|
|
2390
|
+
color: var(--profiler-text);
|
|
2391
|
+
font-family: var(--profiler-font-mono);
|
|
2392
|
+
font-size: var(--profiler-text-xs);
|
|
2393
|
+
width: 200px;
|
|
2394
|
+
transition: border-color var(--profiler-transition-base);
|
|
2395
|
+
}
|
|
2396
|
+
.profiler-flamegraph__search-input:focus {
|
|
2397
|
+
outline: none;
|
|
2398
|
+
border-color: var(--profiler-accent);
|
|
2399
|
+
}
|
|
2400
|
+
.profiler-flamegraph__match-count {
|
|
2401
|
+
font-size: var(--profiler-text-xs);
|
|
2402
|
+
font-family: var(--profiler-font-mono);
|
|
2403
|
+
color: var(--profiler-text-muted);
|
|
2404
|
+
white-space: nowrap;
|
|
2405
|
+
}
|
|
2113
2406
|
.profiler-flamegraph__canvas-container {
|
|
2114
2407
|
position: relative;
|
|
2115
2408
|
border: 1px solid var(--profiler-border);
|
|
@@ -718,11 +718,26 @@
|
|
|
718
718
|
if (!bytes) return "-";
|
|
719
719
|
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
|
720
720
|
}
|
|
721
|
+
var PRESETS = [
|
|
722
|
+
{ key: "slow", label: "Slow" },
|
|
723
|
+
{ key: "many_queries", label: "Many queries" },
|
|
724
|
+
{ key: "errors", label: "Errors" },
|
|
725
|
+
{ key: "has_exception", label: "Has exception" }
|
|
726
|
+
];
|
|
721
727
|
function ProfileList() {
|
|
728
|
+
const params = new URLSearchParams(window.location.search);
|
|
722
729
|
const initialSection = () => {
|
|
723
|
-
const s3 =
|
|
730
|
+
const s3 = params.get("section");
|
|
724
731
|
return s3 === "http" || s3 === "jobs" || s3 === "outbound" ? s3 : "http";
|
|
725
732
|
};
|
|
733
|
+
const initialSort = () => {
|
|
734
|
+
const col = params.get("sort");
|
|
735
|
+
const dir = params.get("dir");
|
|
736
|
+
return {
|
|
737
|
+
col: col === "duration" || col === "memory" || col === "status" || col === "queries" ? col : null,
|
|
738
|
+
dir: dir === "desc" ? "desc" : "asc"
|
|
739
|
+
};
|
|
740
|
+
};
|
|
726
741
|
const [section, setSection] = d2(initialSection);
|
|
727
742
|
const [profiles, setProfiles] = d2([]);
|
|
728
743
|
const [jobs, setJobs] = d2([]);
|
|
@@ -740,6 +755,11 @@
|
|
|
740
755
|
const [httpMethod, setHttpMethod] = d2("");
|
|
741
756
|
const [httpStatus, setHttpStatus] = d2("");
|
|
742
757
|
const [httpDuration, setHttpDuration] = d2("");
|
|
758
|
+
const [httpPreset, setHttpPreset] = d2(() => {
|
|
759
|
+
const p3 = params.get("preset");
|
|
760
|
+
return PRESETS.some((pr) => pr.key === p3) ? p3 : "";
|
|
761
|
+
});
|
|
762
|
+
const [httpSort, setHttpSort] = d2(initialSort);
|
|
743
763
|
const [jobSearch, setJobSearch] = d2("");
|
|
744
764
|
const [jobStatus, setJobStatus] = d2("");
|
|
745
765
|
const [jobDuration, setJobDuration] = d2("");
|
|
@@ -759,6 +779,30 @@
|
|
|
759
779
|
if (section === "jobs") loadJobs();
|
|
760
780
|
if (section === "outbound") loadOutbound();
|
|
761
781
|
}, []);
|
|
782
|
+
y2(() => {
|
|
783
|
+
const url = new URL(window.location.href);
|
|
784
|
+
if (httpSort.col) {
|
|
785
|
+
url.searchParams.set("sort", httpSort.col);
|
|
786
|
+
url.searchParams.set("dir", httpSort.dir);
|
|
787
|
+
} else {
|
|
788
|
+
url.searchParams.delete("sort");
|
|
789
|
+
url.searchParams.delete("dir");
|
|
790
|
+
}
|
|
791
|
+
if (httpPreset) {
|
|
792
|
+
url.searchParams.set("preset", httpPreset);
|
|
793
|
+
} else {
|
|
794
|
+
url.searchParams.delete("preset");
|
|
795
|
+
}
|
|
796
|
+
history.replaceState(null, "", url.toString());
|
|
797
|
+
}, [httpSort, httpPreset]);
|
|
798
|
+
const toggleHttpSort = (col) => {
|
|
799
|
+
setHttpSort(
|
|
800
|
+
(prev) => prev.col === col ? { col, dir: prev.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }
|
|
801
|
+
);
|
|
802
|
+
};
|
|
803
|
+
const togglePreset = (key) => {
|
|
804
|
+
setHttpPreset((prev) => prev === key ? "" : key);
|
|
805
|
+
};
|
|
762
806
|
const loadJobs = () => {
|
|
763
807
|
if (jobsLoaded) return;
|
|
764
808
|
setLoadingJobs(true);
|
|
@@ -796,6 +840,8 @@
|
|
|
796
840
|
setHttpMethod("");
|
|
797
841
|
setHttpStatus("");
|
|
798
842
|
setHttpDuration("");
|
|
843
|
+
setHttpPreset("");
|
|
844
|
+
setHttpSort({ col: null, dir: "asc" });
|
|
799
845
|
setJobSearch("");
|
|
800
846
|
setJobStatus("");
|
|
801
847
|
setJobDuration("");
|
|
@@ -870,8 +916,36 @@
|
|
|
870
916
|
if (httpDuration) {
|
|
871
917
|
if (httpDuration === "lt100" ? p3.duration >= 100 : p3.duration < parseInt(httpDuration)) return false;
|
|
872
918
|
}
|
|
919
|
+
if (httpPreset === "slow" && p3.duration < 500) return false;
|
|
920
|
+
if (httpPreset === "many_queries" && (p3.collectors_data?.database?.total_queries ?? 0) <= 20) return false;
|
|
921
|
+
if (httpPreset === "errors" && p3.status < 500) return false;
|
|
922
|
+
if (httpPreset === "has_exception" && !p3.collectors_data?.exception) return false;
|
|
873
923
|
return true;
|
|
874
924
|
});
|
|
925
|
+
const sortedProfiles = httpSort.col ? [...filteredProfiles].sort((a3, b) => {
|
|
926
|
+
let av, bv;
|
|
927
|
+
switch (httpSort.col) {
|
|
928
|
+
case "duration":
|
|
929
|
+
av = a3.duration;
|
|
930
|
+
bv = b.duration;
|
|
931
|
+
break;
|
|
932
|
+
case "memory":
|
|
933
|
+
av = a3.memory ?? 0;
|
|
934
|
+
bv = b.memory ?? 0;
|
|
935
|
+
break;
|
|
936
|
+
case "status":
|
|
937
|
+
av = a3.status;
|
|
938
|
+
bv = b.status;
|
|
939
|
+
break;
|
|
940
|
+
case "queries":
|
|
941
|
+
av = a3.collectors_data?.database?.total_queries ?? 0;
|
|
942
|
+
bv = b.collectors_data?.database?.total_queries ?? 0;
|
|
943
|
+
break;
|
|
944
|
+
default:
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
return httpSort.dir === "asc" ? av - bv : bv - av;
|
|
948
|
+
}) : filteredProfiles;
|
|
875
949
|
const filteredJobs = jobs.filter((p3) => {
|
|
876
950
|
if (jobSearch && !p3.path.toLowerCase().includes(jobSearch.toLowerCase())) return false;
|
|
877
951
|
if (jobStatus === "failed" && p3.status !== 500) return false;
|
|
@@ -894,9 +968,13 @@
|
|
|
894
968
|
}
|
|
895
969
|
return true;
|
|
896
970
|
});
|
|
897
|
-
const httpFiltersActive = !!(httpSearch || httpMethod || httpStatus || httpDuration);
|
|
971
|
+
const httpFiltersActive = !!(httpSearch || httpMethod || httpStatus || httpDuration || httpPreset);
|
|
898
972
|
const jobFiltersActive = !!(jobSearch || jobStatus || jobDuration);
|
|
899
973
|
const outboundFiltersActive = !!(outboundSearch || outboundMethod || outboundStatus);
|
|
974
|
+
const sortIcon = (col) => {
|
|
975
|
+
if (httpSort.col !== col) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
|
|
976
|
+
return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: httpSort.dir === "asc" ? "\u25B2" : "\u25BC" });
|
|
977
|
+
};
|
|
900
978
|
return /* @__PURE__ */ u3("div", { class: "container", children: [
|
|
901
979
|
/* @__PURE__ */ u3("div", { class: "header", children: [
|
|
902
980
|
/* @__PURE__ */ u3("h1", { children: [
|
|
@@ -925,6 +1003,15 @@
|
|
|
925
1003
|
/* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No profiles found" }),
|
|
926
1004
|
/* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Make some requests to your application to see profiling data" })
|
|
927
1005
|
] }) : /* @__PURE__ */ u3(k, { children: [
|
|
1006
|
+
/* @__PURE__ */ u3("div", { class: "profiler-action-bar profiler-mb-2", children: /* @__PURE__ */ u3("div", { class: "profiler-filter-group", children: PRESETS.map((preset) => /* @__PURE__ */ u3(
|
|
1007
|
+
"button",
|
|
1008
|
+
{
|
|
1009
|
+
class: `profiler-preset-btn${httpPreset === preset.key ? " profiler-preset-btn--active" : ""}`,
|
|
1010
|
+
onClick: () => togglePreset(preset.key),
|
|
1011
|
+
children: preset.label
|
|
1012
|
+
},
|
|
1013
|
+
preset.key
|
|
1014
|
+
)) }) }),
|
|
928
1015
|
/* @__PURE__ */ u3("div", { class: "profiler-action-bar profiler-mb-3", children: [
|
|
929
1016
|
/* @__PURE__ */ u3("div", { class: "profiler-filter-group", children: [
|
|
930
1017
|
/* @__PURE__ */ u3(
|
|
@@ -974,13 +1061,26 @@
|
|
|
974
1061
|
/* @__PURE__ */ u3("th", { children: "Time" }),
|
|
975
1062
|
/* @__PURE__ */ u3("th", { children: "Method" }),
|
|
976
1063
|
/* @__PURE__ */ u3("th", { children: "Path" }),
|
|
977
|
-
/* @__PURE__ */ u3("th", {
|
|
978
|
-
|
|
979
|
-
|
|
1064
|
+
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "duration" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("duration"), children: [
|
|
1065
|
+
"Duration ",
|
|
1066
|
+
sortIcon("duration")
|
|
1067
|
+
] }),
|
|
1068
|
+
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "queries" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("queries"), children: [
|
|
1069
|
+
"Queries ",
|
|
1070
|
+
sortIcon("queries")
|
|
1071
|
+
] }),
|
|
1072
|
+
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "memory" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("memory"), children: [
|
|
1073
|
+
"Memory ",
|
|
1074
|
+
sortIcon("memory")
|
|
1075
|
+
] }),
|
|
1076
|
+
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "status" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("status"), children: [
|
|
1077
|
+
"Status ",
|
|
1078
|
+
sortIcon("status")
|
|
1079
|
+
] }),
|
|
980
1080
|
/* @__PURE__ */ u3("th", { children: "Token" }),
|
|
981
1081
|
/* @__PURE__ */ u3("th", {})
|
|
982
1082
|
] }) }),
|
|
983
|
-
/* @__PURE__ */ u3("tbody", { children:
|
|
1083
|
+
/* @__PURE__ */ u3("tbody", { children: sortedProfiles.map((p3) => /* @__PURE__ */ u3("tr", { children: [
|
|
984
1084
|
/* @__PURE__ */ u3("td", { children: formatTime(p3.started_at) }),
|
|
985
1085
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: methodClass(p3.method), children: p3.method }) }),
|
|
986
1086
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("a", { href: `${BASE}/profiles/${p3.token}`, children: p3.path }) }),
|
|
@@ -988,6 +1088,7 @@
|
|
|
988
1088
|
p3.duration.toFixed(2),
|
|
989
1089
|
" ms"
|
|
990
1090
|
] }) }),
|
|
1091
|
+
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "\u2014" }),
|
|
991
1092
|
/* @__PURE__ */ u3("td", { children: formatMemory(p3.memory) }),
|
|
992
1093
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
|
|
993
1094
|
/* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
|
|
@@ -1263,10 +1364,154 @@
|
|
|
1263
1364
|
}
|
|
1264
1365
|
|
|
1265
1366
|
// app/assets/typescript/profiler/components/dashboard/tabs/DatabaseTab.tsx
|
|
1266
|
-
function
|
|
1367
|
+
function normalizeSql(sql) {
|
|
1368
|
+
return sql.replace(/\$\d+/g, "?").replace(/\b\d+\b/g, "?").replace(/'[^']*'/g, "?").replace(/"[^"]*"/g, "?").trim();
|
|
1369
|
+
}
|
|
1370
|
+
function computeN1Groups(queries) {
|
|
1371
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1372
|
+
queries.forEach((query, index) => {
|
|
1373
|
+
if (query.cached || query.transaction) return;
|
|
1374
|
+
const pattern = normalizeSql(query.sql);
|
|
1375
|
+
if (!groups.has(pattern)) {
|
|
1376
|
+
groups.set(pattern, { indices: [], backtrace: query.backtrace ?? [] });
|
|
1377
|
+
}
|
|
1378
|
+
groups.get(pattern).indices.push(index);
|
|
1379
|
+
});
|
|
1380
|
+
const result = [];
|
|
1381
|
+
groups.forEach(({ indices, backtrace }, pattern) => {
|
|
1382
|
+
if (indices.length >= 3) {
|
|
1383
|
+
result.push({ pattern, indices, backtrace });
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
return result.sort((a3, b) => b.indices.length - a3.indices.length);
|
|
1387
|
+
}
|
|
1388
|
+
function renderPlanNode(node, depth = 0) {
|
|
1389
|
+
const type = node["Node Type"] ?? "";
|
|
1390
|
+
const actualRows = node["Actual Rows"] ?? 0;
|
|
1391
|
+
const planRows = node["Plan Rows"] ?? 1;
|
|
1392
|
+
const isExpensive = type.includes("Seq Scan") || type.includes("Hash Join") || type.includes("Nested Loop") || type.includes("Filter") || actualRows > 0 && planRows > 0 && actualRows > planRows * 10;
|
|
1393
|
+
const children = node["Plans"] ?? [];
|
|
1394
|
+
return /* @__PURE__ */ u3("div", { class: `profiler-explain-node${isExpensive ? " profiler-explain-node--expensive" : ""}`, style: `margin-left:${depth * 14}px`, children: [
|
|
1395
|
+
/* @__PURE__ */ u3("span", { class: "profiler-explain-node__type", children: type }),
|
|
1396
|
+
node["Relation Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1397
|
+
" on ",
|
|
1398
|
+
node["Relation Name"]
|
|
1399
|
+
] }),
|
|
1400
|
+
node["Index Name"] && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1401
|
+
" using ",
|
|
1402
|
+
node["Index Name"]
|
|
1403
|
+
] }),
|
|
1404
|
+
/* @__PURE__ */ u3("div", { class: "profiler-explain-node__stats profiler-text--xs profiler-text--muted", children: [
|
|
1405
|
+
node["Actual Total Time"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1406
|
+
"time: ",
|
|
1407
|
+
node["Actual Total Time"].toFixed(3),
|
|
1408
|
+
"ms"
|
|
1409
|
+
] }),
|
|
1410
|
+
node["Actual Rows"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1411
|
+
"rows: ",
|
|
1412
|
+
actualRows,
|
|
1413
|
+
" (est: ",
|
|
1414
|
+
planRows,
|
|
1415
|
+
")"
|
|
1416
|
+
] }),
|
|
1417
|
+
node["Total Cost"] != null && /* @__PURE__ */ u3("span", { children: [
|
|
1418
|
+
"cost: ",
|
|
1419
|
+
node["Total Cost"].toFixed(2)
|
|
1420
|
+
] }),
|
|
1421
|
+
node["Filter"] && /* @__PURE__ */ u3("span", { class: "profiler-text--warning", children: [
|
|
1422
|
+
"filter: ",
|
|
1423
|
+
node["Filter"]
|
|
1424
|
+
] })
|
|
1425
|
+
] }),
|
|
1426
|
+
children.map((child, i3) => renderPlanNode(child, depth + 1))
|
|
1427
|
+
] });
|
|
1428
|
+
}
|
|
1429
|
+
function ExplainModal({ state, onClose }) {
|
|
1430
|
+
if (!state.open) return null;
|
|
1431
|
+
const renderResult = () => {
|
|
1432
|
+
if (state.loading) return /* @__PURE__ */ u3("div", { class: "profiler-text--muted", children: "Running EXPLAIN ANALYZE\u2026" });
|
|
1433
|
+
if (state.error) return /* @__PURE__ */ u3("div", { class: "profiler-text--error", children: state.error });
|
|
1434
|
+
if (!state.result) return null;
|
|
1435
|
+
if (state.format === "json") {
|
|
1436
|
+
try {
|
|
1437
|
+
const parsed = typeof state.result === "string" ? JSON.parse(state.result) : state.result;
|
|
1438
|
+
const plans = Array.isArray(parsed) ? parsed : [parsed];
|
|
1439
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-explain-result", children: plans.map((entry, i3) => /* @__PURE__ */ u3("div", { children: [
|
|
1440
|
+
renderPlanNode(entry["Plan"] ?? entry),
|
|
1441
|
+
entry["Planning Time"] != null && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
|
|
1442
|
+
"Planning: ",
|
|
1443
|
+
entry["Planning Time"].toFixed(3),
|
|
1444
|
+
"ms",
|
|
1445
|
+
entry["Execution Time"] != null && /* @__PURE__ */ u3(k, { children: [
|
|
1446
|
+
" \xB7 Execution: ",
|
|
1447
|
+
entry["Execution Time"].toFixed(3),
|
|
1448
|
+
"ms"
|
|
1449
|
+
] })
|
|
1450
|
+
] })
|
|
1451
|
+
] }, i3)) });
|
|
1452
|
+
} catch {
|
|
1453
|
+
return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return /* @__PURE__ */ u3("pre", { class: "profiler-explain-result", children: String(state.result) });
|
|
1457
|
+
};
|
|
1458
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-modal__overlay", onClick: onClose, children: /* @__PURE__ */ u3("div", { class: "profiler-modal", onClick: (e3) => e3.stopPropagation(), children: [
|
|
1459
|
+
/* @__PURE__ */ u3("div", { class: "profiler-modal__header", children: [
|
|
1460
|
+
/* @__PURE__ */ u3("span", { class: "profiler-modal__title", children: "EXPLAIN ANALYZE" }),
|
|
1461
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: state.adapter }),
|
|
1462
|
+
/* @__PURE__ */ u3("button", { class: "profiler-modal__close", onClick: onClose, children: "\xD7" })
|
|
1463
|
+
] }),
|
|
1464
|
+
/* @__PURE__ */ u3("div", { class: "profiler-modal__body", children: renderResult() })
|
|
1465
|
+
] }) });
|
|
1466
|
+
}
|
|
1467
|
+
function DatabaseTab({ dbData, token }) {
|
|
1468
|
+
const [openBacktraces, setOpenBacktraces] = d2(/* @__PURE__ */ new Set());
|
|
1469
|
+
const [explainState, setExplainState] = d2({
|
|
1470
|
+
open: false,
|
|
1471
|
+
loading: false,
|
|
1472
|
+
result: null,
|
|
1473
|
+
format: "text",
|
|
1474
|
+
adapter: "",
|
|
1475
|
+
error: null
|
|
1476
|
+
});
|
|
1267
1477
|
if (!dbData?.queries) {
|
|
1268
1478
|
return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No database queries recorded" }) });
|
|
1269
1479
|
}
|
|
1480
|
+
const n1Groups = computeN1Groups(dbData.queries);
|
|
1481
|
+
const n1IndexSet = new Set(n1Groups.flatMap((g2) => g2.indices));
|
|
1482
|
+
const toggleBacktrace = (pattern) => {
|
|
1483
|
+
setOpenBacktraces((prev) => {
|
|
1484
|
+
const next = new Set(prev);
|
|
1485
|
+
next.has(pattern) ? next.delete(pattern) : next.add(pattern);
|
|
1486
|
+
return next;
|
|
1487
|
+
});
|
|
1488
|
+
};
|
|
1489
|
+
const runExplain = async (queryIndex) => {
|
|
1490
|
+
setExplainState({ open: true, loading: true, result: null, format: "text", adapter: "", error: null });
|
|
1491
|
+
try {
|
|
1492
|
+
const res = await fetch("/_profiler/api/explain", {
|
|
1493
|
+
method: "POST",
|
|
1494
|
+
headers: { "Content-Type": "application/json" },
|
|
1495
|
+
body: JSON.stringify({ token, query_index: queryIndex })
|
|
1496
|
+
});
|
|
1497
|
+
if (!res.ok) {
|
|
1498
|
+
const body = await res.json().catch(() => ({}));
|
|
1499
|
+
setExplainState((s3) => ({ ...s3, loading: false, error: body.error ?? `HTTP ${res.status}` }));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const data = await res.json();
|
|
1503
|
+
setExplainState((s3) => ({
|
|
1504
|
+
...s3,
|
|
1505
|
+
loading: false,
|
|
1506
|
+
result: data.result,
|
|
1507
|
+
format: data.format ?? "text",
|
|
1508
|
+
adapter: data.adapter ?? ""
|
|
1509
|
+
}));
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
setExplainState((s3) => ({ ...s3, loading: false, error: err.message ?? "Request failed" }));
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
const closeExplain = () => setExplainState((s3) => ({ ...s3, open: false }));
|
|
1270
1515
|
return /* @__PURE__ */ u3(k, { children: [
|
|
1271
1516
|
/* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
|
|
1272
1517
|
"Database Queries (",
|
|
@@ -1290,19 +1535,60 @@
|
|
|
1290
1535
|
/* @__PURE__ */ u3("strong", { children: dbData.cached_queries })
|
|
1291
1536
|
] })
|
|
1292
1537
|
] }),
|
|
1293
|
-
|
|
1538
|
+
n1Groups.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
|
|
1539
|
+
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
1540
|
+
/* @__PURE__ */ u3("div", { children: [
|
|
1541
|
+
/* @__PURE__ */ u3("strong", { children: "Potential N+1 detected" }),
|
|
1542
|
+
" \u2014 ",
|
|
1543
|
+
n1Groups.length,
|
|
1544
|
+
" pattern",
|
|
1545
|
+
n1Groups.length > 1 ? "s" : "",
|
|
1546
|
+
" repeated ",
|
|
1547
|
+
n1Groups.reduce((sum, g2) => sum + g2.indices.length, 0),
|
|
1548
|
+
" times total",
|
|
1549
|
+
/* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-1", children: "Queries causing N+1 are highlighted below. Expand each group to see the call stack." })
|
|
1550
|
+
] })
|
|
1551
|
+
] }),
|
|
1552
|
+
n1Groups.map((group) => /* @__PURE__ */ u3("div", { class: "profiler-n1-group profiler-mb-4", children: [
|
|
1553
|
+
/* @__PURE__ */ u3("div", { class: "profiler-n1-group__header", onClick: () => toggleBacktrace(group.pattern), children: [
|
|
1554
|
+
/* @__PURE__ */ u3("span", { class: "profiler-n1-group__count", children: [
|
|
1555
|
+
"N+1 \xB7 ",
|
|
1556
|
+
group.indices.length,
|
|
1557
|
+
"\xD7"
|
|
1558
|
+
] }),
|
|
1559
|
+
/* @__PURE__ */ u3("code", { class: "profiler-n1-group__pattern", children: group.pattern }),
|
|
1560
|
+
/* @__PURE__ */ u3("span", { class: "profiler-n1-group__toggle", children: [
|
|
1561
|
+
openBacktraces.has(group.pattern) ? "\u25B2" : "\u25BC",
|
|
1562
|
+
" backtrace"
|
|
1563
|
+
] })
|
|
1564
|
+
] }),
|
|
1565
|
+
openBacktraces.has(group.pattern) && group.backtrace.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-n1-backtrace", children: group.backtrace.map((frame, i3) => /* @__PURE__ */ u3("div", { class: "profiler-n1-backtrace__frame", children: frame }, i3)) })
|
|
1566
|
+
] }, group.pattern)),
|
|
1567
|
+
dbData.queries.map((query, index) => /* @__PURE__ */ u3("div", { class: [
|
|
1568
|
+
"profiler-query-card",
|
|
1569
|
+
query.slow ? "profiler-query-card--slow" : "",
|
|
1570
|
+
n1IndexSet.has(index) ? "profiler-query-card--n1" : ""
|
|
1571
|
+
].filter(Boolean).join(" "), children: [
|
|
1294
1572
|
/* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
|
|
1295
|
-
/* @__PURE__ */ u3("
|
|
1296
|
-
"
|
|
1297
|
-
|
|
1573
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
|
|
1574
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1575
|
+
"#",
|
|
1576
|
+
index + 1
|
|
1577
|
+
] }),
|
|
1578
|
+
n1IndexSet.has(index) && /* @__PURE__ */ u3("span", { class: "profiler-badge profiler-badge--warning", children: "N+1" }),
|
|
1579
|
+
query.name && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: query.name })
|
|
1298
1580
|
] }),
|
|
1299
|
-
/* @__PURE__ */ u3("
|
|
1300
|
-
query.
|
|
1301
|
-
|
|
1581
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
|
|
1582
|
+
/* @__PURE__ */ u3("span", { class: `profiler-query-card__duration ${query.slow ? "profiler-query-card__duration--slow" : "profiler-query-card__duration--fast"}`, children: [
|
|
1583
|
+
query.duration.toFixed(2),
|
|
1584
|
+
" ms"
|
|
1585
|
+
] }),
|
|
1586
|
+
!query.cached && !query.transaction && /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", onClick: () => runExplain(index), children: "Explain" })
|
|
1302
1587
|
] })
|
|
1303
1588
|
] }),
|
|
1304
1589
|
/* @__PURE__ */ u3("code", { class: "profiler-query-card__code", children: query.sql })
|
|
1305
|
-
] }, index))
|
|
1590
|
+
] }, index)),
|
|
1591
|
+
/* @__PURE__ */ u3(ExplainModal, { state: explainState, onClose: closeExplain })
|
|
1306
1592
|
] });
|
|
1307
1593
|
}
|
|
1308
1594
|
|
|
@@ -1382,6 +1668,7 @@
|
|
|
1382
1668
|
this.dpr = 1;
|
|
1383
1669
|
this.hoveredFrame = null;
|
|
1384
1670
|
this.zoomStack = [];
|
|
1671
|
+
this.searchQuery = "";
|
|
1385
1672
|
this.isPanning = false;
|
|
1386
1673
|
this.panStartX = 0;
|
|
1387
1674
|
this.panStartViewport = { start: 0, end: 0 };
|
|
@@ -1604,6 +1891,10 @@
|
|
|
1604
1891
|
this.render();
|
|
1605
1892
|
}
|
|
1606
1893
|
}
|
|
1894
|
+
setSearchQuery(query) {
|
|
1895
|
+
this.searchQuery = query;
|
|
1896
|
+
this.render();
|
|
1897
|
+
}
|
|
1607
1898
|
render() {
|
|
1608
1899
|
const ctx = this.ctx;
|
|
1609
1900
|
const w3 = this.canvas.width / this.dpr;
|
|
@@ -1613,6 +1904,15 @@
|
|
|
1613
1904
|
const style = getComputedStyle(this.canvas);
|
|
1614
1905
|
const textColor = style.getPropertyValue("--profiler-text").trim() || "#eef2f7";
|
|
1615
1906
|
const textMuted = style.getPropertyValue("--profiler-text-muted").trim() || "#5e7080";
|
|
1907
|
+
const searchLower = this.searchQuery.toLowerCase();
|
|
1908
|
+
const hasSearch = searchLower.length > 0;
|
|
1909
|
+
if (hasSearch && this.callbacks.onSearchResults) {
|
|
1910
|
+
let matchCount = 0;
|
|
1911
|
+
for (const f4 of this.frames) {
|
|
1912
|
+
if (f4.node.name.toLowerCase().includes(searchLower)) matchCount++;
|
|
1913
|
+
}
|
|
1914
|
+
this.callbacks.onSearchResults(matchCount, this.frames.length);
|
|
1915
|
+
}
|
|
1616
1916
|
for (const frame of this.frames) {
|
|
1617
1917
|
const x2 = (frame.absStart - this.viewport.start) / vpRange * w3;
|
|
1618
1918
|
const fw = (frame.absEnd - frame.absStart) / vpRange * w3;
|
|
@@ -1620,8 +1920,9 @@
|
|
|
1620
1920
|
if (x2 + fw < 0 || x2 > w3 || fw < 0.5) continue;
|
|
1621
1921
|
const color = CATEGORY_COLORS[frame.node.category] || "#a78bfa";
|
|
1622
1922
|
const isHovered = frame === this.hoveredFrame;
|
|
1923
|
+
const isMatch = !hasSearch || frame.node.name.toLowerCase().includes(searchLower);
|
|
1623
1924
|
ctx.fillStyle = isHovered ? this.lightenColor(color, 0.2) : color;
|
|
1624
|
-
ctx.globalAlpha = isHovered ? 1 : 0.85;
|
|
1925
|
+
ctx.globalAlpha = hasSearch && !isMatch ? 0.2 : isHovered ? 1 : 0.85;
|
|
1625
1926
|
this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
|
|
1626
1927
|
ctx.fill();
|
|
1627
1928
|
ctx.globalAlpha = 1;
|
|
@@ -1630,8 +1931,15 @@
|
|
|
1630
1931
|
ctx.lineWidth = 1.5;
|
|
1631
1932
|
this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
|
|
1632
1933
|
ctx.stroke();
|
|
1934
|
+
} else if (hasSearch && isMatch) {
|
|
1935
|
+
ctx.strokeStyle = "#ffffff";
|
|
1936
|
+
ctx.lineWidth = 1;
|
|
1937
|
+
ctx.globalAlpha = 0.5;
|
|
1938
|
+
this.roundRect(ctx, x2, y3, fw, FRAME_HEIGHT, 3);
|
|
1939
|
+
ctx.stroke();
|
|
1940
|
+
ctx.globalAlpha = 1;
|
|
1633
1941
|
}
|
|
1634
|
-
if (fw > MIN_TEXT_WIDTH) {
|
|
1942
|
+
if (fw > MIN_TEXT_WIDTH && isMatch) {
|
|
1635
1943
|
ctx.fillStyle = this.getTextColor(color);
|
|
1636
1944
|
ctx.font = '11px "JetBrains Mono", monospace';
|
|
1637
1945
|
ctx.textBaseline = "middle";
|
|
@@ -1856,7 +2164,11 @@
|
|
|
1856
2164
|
const rendererRef = A2(null);
|
|
1857
2165
|
const tooltipRef = A2(null);
|
|
1858
2166
|
const breadcrumbsRef = A2(null);
|
|
2167
|
+
const searchInputRef = A2(null);
|
|
1859
2168
|
const [isZoomed, setIsZoomed] = d2(false);
|
|
2169
|
+
const [searchQuery, setSearchQuery] = d2("");
|
|
2170
|
+
const [matchCount, setMatchCount] = d2(0);
|
|
2171
|
+
const [totalCount, setTotalCount] = d2(0);
|
|
1860
2172
|
const data = flamegraphData;
|
|
1861
2173
|
y2(() => {
|
|
1862
2174
|
if (!data?.root_events?.length || !canvasRef.current || !containerRef.current) return;
|
|
@@ -1888,6 +2200,10 @@
|
|
|
1888
2200
|
onZoomChange: (ancestors) => {
|
|
1889
2201
|
breadcrumbs.update(ancestors);
|
|
1890
2202
|
setIsZoomed(ancestors.length > 0);
|
|
2203
|
+
},
|
|
2204
|
+
onSearchResults: (match, total) => {
|
|
2205
|
+
setMatchCount(match);
|
|
2206
|
+
setTotalCount(total);
|
|
1891
2207
|
}
|
|
1892
2208
|
});
|
|
1893
2209
|
rendererRef.current = renderer;
|
|
@@ -1906,6 +2222,23 @@
|
|
|
1906
2222
|
breadcrumbsRef.current = null;
|
|
1907
2223
|
};
|
|
1908
2224
|
}, [data]);
|
|
2225
|
+
y2(() => {
|
|
2226
|
+
rendererRef.current?.setSearchQuery(searchQuery);
|
|
2227
|
+
if (!searchQuery) {
|
|
2228
|
+
setMatchCount(0);
|
|
2229
|
+
setTotalCount(0);
|
|
2230
|
+
}
|
|
2231
|
+
}, [searchQuery]);
|
|
2232
|
+
y2(() => {
|
|
2233
|
+
const onKeyDown = (e3) => {
|
|
2234
|
+
if ((e3.ctrlKey || e3.metaKey) && e3.key === "f") {
|
|
2235
|
+
e3.preventDefault();
|
|
2236
|
+
searchInputRef.current?.focus();
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
document.addEventListener("keydown", onKeyDown, { capture: true });
|
|
2240
|
+
return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
|
|
2241
|
+
}, []);
|
|
1909
2242
|
if (!data?.root_events?.length) {
|
|
1910
2243
|
if (!perfData?.events?.length) {
|
|
1911
2244
|
return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No performance events recorded" }) });
|
|
@@ -1976,6 +2309,24 @@
|
|
|
1976
2309
|
] }, cat)) }),
|
|
1977
2310
|
/* @__PURE__ */ u3("div", { class: "profiler-flamegraph__controls", children: [
|
|
1978
2311
|
isZoomed && /* @__PURE__ */ u3("button", { class: "profiler-flamegraph__reset", onClick: handleReset, children: "Reset Zoom" }),
|
|
2312
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flamegraph__search", children: [
|
|
2313
|
+
/* @__PURE__ */ u3(
|
|
2314
|
+
"input",
|
|
2315
|
+
{
|
|
2316
|
+
ref: searchInputRef,
|
|
2317
|
+
type: "text",
|
|
2318
|
+
class: "profiler-flamegraph__search-input",
|
|
2319
|
+
placeholder: "Search events\u2026 (Ctrl+F)",
|
|
2320
|
+
value: searchQuery,
|
|
2321
|
+
onInput: (e3) => setSearchQuery(e3.target.value)
|
|
2322
|
+
}
|
|
2323
|
+
),
|
|
2324
|
+
searchQuery && /* @__PURE__ */ u3("span", { class: "profiler-flamegraph__match-count", children: [
|
|
2325
|
+
matchCount,
|
|
2326
|
+
" / ",
|
|
2327
|
+
totalCount
|
|
2328
|
+
] })
|
|
2329
|
+
] }),
|
|
1979
2330
|
/* @__PURE__ */ u3("span", { class: "profiler-flamegraph__hint", children: "Click to zoom, scroll to zoom in/out, drag to pan" })
|
|
1980
2331
|
] }),
|
|
1981
2332
|
/* @__PURE__ */ u3("div", { class: "profiler-flamegraph__canvas-container", ref: containerRef, children: /* @__PURE__ */ u3(
|
|
@@ -2545,7 +2896,7 @@
|
|
|
2545
2896
|
activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
|
|
2546
2897
|
activeTab === "request" && /* @__PURE__ */ u3(RequestTab, { profile }),
|
|
2547
2898
|
activeTab === "dump" && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
|
|
2548
|
-
activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"] }),
|
|
2899
|
+
activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
|
|
2549
2900
|
activeTab === "ajax" && /* @__PURE__ */ u3(AjaxTab, { ajaxData: cd["ajax"] }),
|
|
2550
2901
|
activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
|
|
2551
2902
|
activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"] }),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "profiler/explain_runner"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Api
|
|
7
|
+
class ExplainController < ApplicationController
|
|
8
|
+
skip_before_action :verify_authenticity_token
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
unless Profiler.configuration.enabled
|
|
12
|
+
return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
token = params[:token].to_s
|
|
16
|
+
query_index = params[:query_index].to_i
|
|
17
|
+
|
|
18
|
+
if token.blank?
|
|
19
|
+
return render json: { error: "token is required" }, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result = Profiler::ExplainRunner.run(token, query_index)
|
|
23
|
+
render json: result
|
|
24
|
+
rescue ArgumentError => e
|
|
25
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
26
|
+
rescue => e
|
|
27
|
+
render json: { error: e.message }, status: :internal_server_error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
# Shared service for running EXPLAIN ANALYZE on a stored query.
|
|
5
|
+
# Used by both the HTTP API controller and the MCP explain_query tool.
|
|
6
|
+
module ExplainRunner
|
|
7
|
+
# @param profile_token [String]
|
|
8
|
+
# @param query_index [Integer]
|
|
9
|
+
# @return [Hash] { result:, format: "json"|"text", adapter: String }
|
|
10
|
+
# or raises ArgumentError / RuntimeError
|
|
11
|
+
def self.run(profile_token, query_index)
|
|
12
|
+
unless Profiler.configuration.enabled
|
|
13
|
+
raise SecurityError, "EXPLAIN is only available when the profiler is enabled"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
profile = Profiler.storage.load(profile_token)
|
|
17
|
+
raise ArgumentError, "Profile not found: #{profile_token}" unless profile
|
|
18
|
+
|
|
19
|
+
db_data = profile.collector_data("database")
|
|
20
|
+
raise ArgumentError, "No database data in this profile" unless db_data && db_data["queries"]
|
|
21
|
+
|
|
22
|
+
queries = db_data["queries"]
|
|
23
|
+
query_index = query_index.to_i
|
|
24
|
+
unless query_index >= 0 && query_index < queries.size
|
|
25
|
+
raise ArgumentError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
query = queries[query_index]
|
|
29
|
+
sql = query["sql"].to_s
|
|
30
|
+
binds = Array(query["binds"])
|
|
31
|
+
|
|
32
|
+
conn = ActiveRecord::Base.connection
|
|
33
|
+
adapter = conn.adapter_name.downcase
|
|
34
|
+
full_sql = reconstruct_sql(sql, binds, conn, adapter)
|
|
35
|
+
|
|
36
|
+
explain_sql, format = build_explain_statement(full_sql, adapter)
|
|
37
|
+
|
|
38
|
+
rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
|
|
39
|
+
|
|
40
|
+
result = if format == "json"
|
|
41
|
+
# PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
|
|
42
|
+
raw = rows.first&.values&.first.to_s
|
|
43
|
+
JSON.parse(raw) rescue raw
|
|
44
|
+
else
|
|
45
|
+
rows.map { |r| r.values.join("\t") }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ result: result, format: format, adapter: adapter }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def self.reconstruct_sql(sql, binds, conn, adapter)
|
|
54
|
+
return sql if binds.empty?
|
|
55
|
+
|
|
56
|
+
if adapter.include?("postgresql")
|
|
57
|
+
result = sql.dup
|
|
58
|
+
binds.each_with_index do |value, i|
|
|
59
|
+
result = result.gsub("$#{i + 1}", conn.quote(value))
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
else
|
|
63
|
+
# MySQL / SQLite: replace ? sequentially
|
|
64
|
+
result = sql.dup
|
|
65
|
+
binds.each do |value|
|
|
66
|
+
result = result.sub("?", conn.quote(value))
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.build_explain_statement(sql, adapter)
|
|
73
|
+
if adapter.include?("postgresql")
|
|
74
|
+
["EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{sql}", "json"]
|
|
75
|
+
elsif adapter.include?("mysql")
|
|
76
|
+
["EXPLAIN FORMAT=JSON #{sql}", "json"]
|
|
77
|
+
else
|
|
78
|
+
["EXPLAIN QUERY PLAN #{sql}", "text"]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -14,15 +14,15 @@ module Profiler
|
|
|
14
14
|
db_data = profile.collector_data("database")
|
|
15
15
|
next unless db_data && db_data["queries"]
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
.transform_values(&:count)
|
|
17
|
+
query_groups = db_data["queries"].group_by { |q| normalize_sql(q["sql"]) }
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
query_groups.each do |normalized_sql, queries|
|
|
21
20
|
pattern_map[normalized_sql] << {
|
|
22
21
|
token: profile.token,
|
|
23
22
|
path: profile.path,
|
|
24
|
-
count:
|
|
25
|
-
timestamp: profile.started_at&.iso8601
|
|
23
|
+
count: queries.size,
|
|
24
|
+
timestamp: profile.started_at&.iso8601,
|
|
25
|
+
backtrace: (queries.first["backtrace"] || []).first(3)
|
|
26
26
|
}
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -35,7 +35,8 @@ module Profiler
|
|
|
35
35
|
# Sort by total occurrence count descending
|
|
36
36
|
sorted = n1_patterns.map do |sql, occurrences|
|
|
37
37
|
total = occurrences.sum { |o| o[:count] }
|
|
38
|
-
|
|
38
|
+
sample_backtrace = occurrences.find { |o| o[:backtrace].any? }&.dig(:backtrace) || []
|
|
39
|
+
{ sql: sql, total_occurrences: total, backtrace: sample_backtrace, profiles: occurrences }
|
|
39
40
|
end.sort_by { |p| -p[:total_occurrences] }.first(20)
|
|
40
41
|
|
|
41
42
|
{
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Profiler
|
|
|
65
65
|
require_relative "tools/query_profiles"
|
|
66
66
|
require_relative "tools/get_profile_detail"
|
|
67
67
|
require_relative "tools/analyze_queries"
|
|
68
|
+
require_relative "tools/explain_query"
|
|
68
69
|
require_relative "tools/get_profile_ajax"
|
|
69
70
|
require_relative "tools/get_profile_dumps"
|
|
70
71
|
require_relative "tools/get_profile_http"
|
|
@@ -116,6 +117,18 @@ module Profiler
|
|
|
116
117
|
},
|
|
117
118
|
handler: Tools::AnalyzeQueries
|
|
118
119
|
),
|
|
120
|
+
define_tool(
|
|
121
|
+
name: "explain_query",
|
|
122
|
+
description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only available in development/test environments.",
|
|
123
|
+
input_schema: {
|
|
124
|
+
properties: {
|
|
125
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
126
|
+
query_index: { type: "integer", description: "Zero-based index of the query within the profile's database queries list (required)" }
|
|
127
|
+
},
|
|
128
|
+
required: ["token", "query_index"]
|
|
129
|
+
},
|
|
130
|
+
handler: Tools::ExplainQuery
|
|
131
|
+
),
|
|
119
132
|
define_tool(
|
|
120
133
|
name: "get_profile_ajax",
|
|
121
134
|
description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
|
|
@@ -59,8 +59,8 @@ module Profiler
|
|
|
59
59
|
slow_threshold = Profiler.configuration.slow_query_threshold
|
|
60
60
|
slow_queries = queries.select { |q| q["duration"] > slow_threshold }
|
|
61
61
|
query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
|
|
62
|
-
.transform_values
|
|
63
|
-
.select { |_,
|
|
62
|
+
.transform_values { |qs| { count: qs.size, backtrace: qs.first["backtrace"] || [] } }
|
|
63
|
+
.select { |_, v| v[:count] >= 3 }
|
|
64
64
|
|
|
65
65
|
unless summary_only
|
|
66
66
|
# Detect slow queries
|
|
@@ -81,22 +81,27 @@ module Profiler
|
|
|
81
81
|
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Detect duplicate queries (potential N+1)
|
|
84
|
+
# Detect duplicate queries (potential N+1, threshold: ≥ 3 occurrences)
|
|
85
85
|
if query_counts.any?
|
|
86
86
|
lines << "## ⚠️ Duplicate Queries (Potential N+1)"
|
|
87
|
-
lines << "Found #{query_counts.size}
|
|
87
|
+
lines << "Found #{query_counts.size} query pattern(s) repeated 3+ times:\n"
|
|
88
88
|
|
|
89
|
-
query_counts.sort_by { |_,
|
|
90
|
-
lines << "### Executed #{count} times:"
|
|
89
|
+
query_counts.sort_by { |_, v| -v[:count] }.first(5).each do |sql, v|
|
|
90
|
+
lines << "### Executed #{v[:count]} times:"
|
|
91
91
|
lines << "```sql"
|
|
92
92
|
lines << sql
|
|
93
|
-
lines << "
|
|
93
|
+
lines << "```"
|
|
94
|
+
if v[:backtrace].any?
|
|
95
|
+
lines << "#### Called from:"
|
|
96
|
+
v[:backtrace].first(3).each { |frame| lines << " #{frame}" }
|
|
97
|
+
end
|
|
98
|
+
lines << ""
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n" if query_counts.size > 5
|
|
97
102
|
else
|
|
98
103
|
lines << "## ✅ No Duplicate Queries"
|
|
99
|
-
lines << "No
|
|
104
|
+
lines << "No query pattern repeated 3+ times\n"
|
|
100
105
|
end
|
|
101
106
|
end
|
|
102
107
|
|
|
@@ -106,7 +111,7 @@ module Profiler
|
|
|
106
111
|
lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
|
|
107
112
|
lines << "- **Average Duration:** #{(queries.sum { |q| q['duration'] } / queries.size).round(2)}ms"
|
|
108
113
|
lines << "- **Slow Queries:** #{slow_queries.size}"
|
|
109
|
-
lines << "- **
|
|
114
|
+
lines << "- **N+1 Patterns (≥3×):** #{query_counts.size}"
|
|
110
115
|
lines << "- **Cached Queries:** #{queries.count { |q| q['cached'] }}"
|
|
111
116
|
|
|
112
117
|
lines.join("\n")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "profiler/explain_runner"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
class ExplainQuery
|
|
9
|
+
def self.call(params)
|
|
10
|
+
token = params["token"]
|
|
11
|
+
query_index = params["query_index"]
|
|
12
|
+
|
|
13
|
+
unless token
|
|
14
|
+
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if query_index.nil?
|
|
18
|
+
return [{ type: "text", text: "Error: query_index parameter is required" }]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless Profiler.configuration.enabled
|
|
22
|
+
return [{ type: "text", text: "Error: EXPLAIN is only available when the profiler is enabled" }]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
data = Profiler::ExplainRunner.run(token, query_index.to_i)
|
|
26
|
+
|
|
27
|
+
lines = []
|
|
28
|
+
lines << "# EXPLAIN ANALYZE — Query ##{query_index}"
|
|
29
|
+
lines << "Adapter: `#{data[:adapter]}`\n"
|
|
30
|
+
|
|
31
|
+
if data[:format] == "json"
|
|
32
|
+
lines << "```json"
|
|
33
|
+
lines << JSON.pretty_generate(data[:result])
|
|
34
|
+
lines << "```"
|
|
35
|
+
else
|
|
36
|
+
lines << "```"
|
|
37
|
+
lines << data[:result].to_s
|
|
38
|
+
lines << "```"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[{ type: "text", text: lines.join("\n") }]
|
|
42
|
+
rescue ArgumentError => e
|
|
43
|
+
[{ type: "text", text: "Error: #{e.message}" }]
|
|
44
|
+
rescue => e
|
|
45
|
+
[{ type: "text", text: "EXPLAIN failed: #{e.message}" }]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -107,6 +107,7 @@ files:
|
|
|
107
107
|
- app/assets/builds/profiler.css
|
|
108
108
|
- app/assets/builds/profiler.js
|
|
109
109
|
- app/controllers/profiler/api/ajax_controller.rb
|
|
110
|
+
- app/controllers/profiler/api/explain_controller.rb
|
|
110
111
|
- app/controllers/profiler/api/jobs_controller.rb
|
|
111
112
|
- app/controllers/profiler/api/outbound_http_controller.rb
|
|
112
113
|
- app/controllers/profiler/api/profiles_controller.rb
|
|
@@ -138,6 +139,7 @@ files:
|
|
|
138
139
|
- lib/profiler/collectors/view_collector.rb
|
|
139
140
|
- lib/profiler/configuration.rb
|
|
140
141
|
- lib/profiler/engine.rb
|
|
142
|
+
- lib/profiler/explain_runner.rb
|
|
141
143
|
- lib/profiler/instrumentation/active_job_instrumentation.rb
|
|
142
144
|
- lib/profiler/instrumentation/net_http_instrumentation.rb
|
|
143
145
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
@@ -152,6 +154,7 @@ files:
|
|
|
152
154
|
- lib/profiler/mcp/server.rb
|
|
153
155
|
- lib/profiler/mcp/tools/analyze_queries.rb
|
|
154
156
|
- lib/profiler/mcp/tools/clear_profiles.rb
|
|
157
|
+
- lib/profiler/mcp/tools/explain_query.rb
|
|
155
158
|
- lib/profiler/mcp/tools/get_profile_ajax.rb
|
|
156
159
|
- lib/profiler/mcp/tools/get_profile_detail.rb
|
|
157
160
|
- lib/profiler/mcp/tools/get_profile_dumps.rb
|