rails-profiler 0.12.0 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b25edc08059907d5374748ac0624d804685b83189faabc4bae8b19cc65b0bed9
4
- data.tar.gz: a5e8268d4e59540a91d7fc2b27c44bbb492e221e2ac2f4446c2ad095a2473245
3
+ metadata.gz: 44b1fd924fe2eb7f0483bc36f0e3956e81535da3ce39e60f6e29c091194f22ce
4
+ data.tar.gz: c840abbc0813c4e8c9516c507138e4ead25e9980f5c02a3306461b1a308ff6e3
5
5
  SHA512:
6
- metadata.gz: af7137fb8650d7a40d2291ad66dc4fe25466c77ad2ebb83489f15191114a1a290b6a4eb47e5760744e991212ec0ed05a146d1d9450f4c7d7cbcbd394629ae3b1
7
- data.tar.gz: 197f4016c350ed157b690d52ff44c83ef4ef2c5ae69f7906ad55ec8b44416fb606e41b66e3d0d84124ec378cb2961ac0b6c8bcde08e55c1e45c488648889cea8
6
+ metadata.gz: 3d5307f599d3822291a99d931cacb1dc70218e32a120e136cebe98790e89f803e0ad84a5520b6961279c88048a7c2262c91600d770f07639cb5e31be32ff3e5a
7
+ data.tar.gz: 341cfa488dd91fe1b566dfc7c0a4d28dd6ab42e5fb0eb874250ca4b0482e3bb154d398c1c0919fef09a812a1cae7f897542b5702eb097c83e80e534cbbf777bd
@@ -1957,6 +1957,52 @@ a.profiler-toolbar-item.profiler-text--warning::after {
1957
1957
  opacity: 1;
1958
1958
  }
1959
1959
 
1960
+ @keyframes profiler-shimmer {
1961
+ 0% {
1962
+ background-position: 200% 0;
1963
+ }
1964
+ 100% {
1965
+ background-position: -200% 0;
1966
+ }
1967
+ }
1968
+ .profiler-skeleton__row {
1969
+ display: flex;
1970
+ align-items: center;
1971
+ gap: 20px;
1972
+ padding: 14px 20px;
1973
+ border-bottom: 1px solid var(--profiler-border);
1974
+ }
1975
+ .profiler-skeleton__row:last-child {
1976
+ border-bottom: none;
1977
+ }
1978
+ .profiler-skeleton__cell {
1979
+ height: 12px;
1980
+ border-radius: 3px;
1981
+ background: linear-gradient(90deg, var(--profiler-bg-lighter, #e5e7eb) 25%, var(--profiler-bg-light, #f3f4f6) 50%, var(--profiler-bg-lighter, #e5e7eb) 75%);
1982
+ background-size: 200% 100%;
1983
+ animation: profiler-shimmer 1.4s infinite linear;
1984
+ }
1985
+ .profiler-skeleton__cell--xs {
1986
+ width: 40px;
1987
+ flex-shrink: 0;
1988
+ }
1989
+ .profiler-skeleton__cell--sm {
1990
+ width: 72px;
1991
+ flex-shrink: 0;
1992
+ }
1993
+ .profiler-skeleton__cell--md {
1994
+ width: 120px;
1995
+ flex-shrink: 0;
1996
+ }
1997
+ .profiler-skeleton__cell--lg {
1998
+ width: 180px;
1999
+ flex-shrink: 0;
2000
+ }
2001
+ .profiler-skeleton__cell--flex {
2002
+ flex: 1;
2003
+ min-width: 60px;
2004
+ }
2005
+
1960
2006
  .profiler-timeline {
1961
2007
  margin: 24px 0;
1962
2008
  padding: 24px;
@@ -496,6 +496,63 @@
496
496
  }
497
497
  var PREVIEW_LIMIT = 500;
498
498
  var CSV_ROW_LIMIT = 10;
499
+ function categoryMime(category) {
500
+ const map = {
501
+ json: "application/json",
502
+ xml: "application/xml",
503
+ csv: "text/csv",
504
+ html: "text/html",
505
+ svg: "image/svg+xml"
506
+ };
507
+ return map[category] || "text/plain";
508
+ }
509
+ function mimeToExt(mime) {
510
+ const m3 = mime.split(";")[0].trim().toLowerCase();
511
+ const map = {
512
+ "application/json": ".json",
513
+ "application/ld+json": ".jsonld",
514
+ "application/xml": ".xml",
515
+ "text/xml": ".xml",
516
+ "text/csv": ".csv",
517
+ "application/csv": ".csv",
518
+ "text/html": ".html",
519
+ "text/plain": ".txt",
520
+ "text/css": ".css",
521
+ "application/javascript": ".js",
522
+ "text/javascript": ".js",
523
+ "image/svg+xml": ".svg",
524
+ "image/png": ".png",
525
+ "image/jpeg": ".jpg",
526
+ "image/gif": ".gif",
527
+ "image/webp": ".webp",
528
+ "image/avif": ".avif",
529
+ "application/pdf": ".pdf",
530
+ "application/zip": ".zip",
531
+ "application/gzip": ".gz",
532
+ "application/octet-stream": ".bin"
533
+ };
534
+ return map[m3] || ".bin";
535
+ }
536
+ function CopyButton({ text }) {
537
+ const [copied, setCopied] = d2(false);
538
+ function copy() {
539
+ navigator.clipboard.writeText(text).then(() => {
540
+ setCopied(true);
541
+ setTimeout(() => setCopied(false), 2e3);
542
+ });
543
+ }
544
+ return /* @__PURE__ */ u3("button", { onClick: copy, class: "profiler-body-download-btn profiler-text--xs", style: "cursor:pointer", children: copied ? "Copied!" : "Copy" });
545
+ }
546
+ function DownloadTextButton({ text, mime }) {
547
+ const [url, setUrl] = d2(null);
548
+ y2(() => {
549
+ const objectUrl = URL.createObjectURL(new Blob([text], { type: mime }));
550
+ setUrl(objectUrl);
551
+ return () => URL.revokeObjectURL(objectUrl);
552
+ }, [text, mime]);
553
+ if (!url) return null;
554
+ return /* @__PURE__ */ u3("a", { href: url, download: `body${mimeToExt(mime)}`, class: "profiler-body-download-btn profiler-text--xs", children: "Download" });
555
+ }
499
556
  function SmartBodyPreview({ body, encoding, headers }) {
500
557
  const [expanded, setExpanded] = d2(false);
501
558
  const [objectUrl, setObjectUrl] = d2(null);
@@ -513,17 +570,21 @@
513
570
  if (!body) return /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "empty" });
514
571
  if (encoding === "base64") {
515
572
  const mime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || "application/octet-stream";
516
- const filename = mime.replace("/", "_").replace(/[^a-z0-9_]/gi, "") + "_download";
573
+ const filename = `body${mimeToExt(mime)}`;
517
574
  return /* @__PURE__ */ u3("div", { class: "profiler-body-binary", children: [
518
575
  objectUrl && (category === "image" || category === "svg") && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
519
576
  objectUrl && category === "pdf" && /* @__PURE__ */ u3("iframe", { src: objectUrl, class: "profiler-body-preview-frame", title: "PDF preview" }),
520
- objectUrl && /* @__PURE__ */ u3("a", { href: objectUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
521
- "Download ",
522
- mime
577
+ /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-top:4px", children: [
578
+ objectUrl && /* @__PURE__ */ u3("a", { href: objectUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
579
+ "Download ",
580
+ mime
581
+ ] }),
582
+ /* @__PURE__ */ u3(CopyButton, { text: body })
523
583
  ] }),
524
584
  !objectUrl && /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "Loading preview\u2026" })
525
585
  ] });
526
586
  }
587
+ const actualMime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || categoryMime(category);
527
588
  if (category === "csv") {
528
589
  const rows = parseCsv(body);
529
590
  const header = rows[0] || [];
@@ -531,6 +592,10 @@
531
592
  const visible = expanded ? dataRows : dataRows.slice(0, CSV_ROW_LIMIT);
532
593
  const hasMore = dataRows.length > CSV_ROW_LIMIT;
533
594
  return /* @__PURE__ */ u3("div", { children: [
595
+ /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-bottom:6px", children: [
596
+ /* @__PURE__ */ u3(CopyButton, { text: body }),
597
+ /* @__PURE__ */ u3(DownloadTextButton, { text: body, mime: actualMime })
598
+ ] }),
534
599
  /* @__PURE__ */ u3("div", { style: "overflow-x:auto", children: /* @__PURE__ */ u3("table", { class: "profiler-body-csv", children: [
535
600
  /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: header.map((h3, i3) => /* @__PURE__ */ u3("th", { children: h3 }, i3)) }) }),
536
601
  /* @__PURE__ */ u3("tbody", { children: visible.map((row, i3) => /* @__PURE__ */ u3("tr", { children: row.map((cell, j3) => /* @__PURE__ */ u3("td", { children: cell }, j3)) }, i3)) })
@@ -550,6 +615,10 @@
550
615
  const preview = expanded ? formatted : formatted.slice(0, PREVIEW_LIMIT);
551
616
  const truncated = formatted.length > PREVIEW_LIMIT && !expanded;
552
617
  return /* @__PURE__ */ u3("div", { children: [
618
+ /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-bottom:6px", children: [
619
+ /* @__PURE__ */ u3(CopyButton, { text: formatted }),
620
+ /* @__PURE__ */ u3(DownloadTextButton, { text: formatted, mime: actualMime })
621
+ ] }),
553
622
  /* @__PURE__ */ u3("pre", { class: "profiler-code profiler-text--xs", style: "white-space:pre-wrap;word-break:break-all;margin:0", children: [
554
623
  preview,
555
624
  truncated ? "\u2026" : ""
@@ -565,11 +634,101 @@
565
634
  )
566
635
  ] });
567
636
  }
637
+ function waterfallBarColor(status, duration) {
638
+ if (status === 0 || status >= 500) return "var(--profiler-error, #ef4444)";
639
+ if (status >= 400) return "var(--profiler-warning, #f59e0b)";
640
+ if (duration >= 500) return "var(--profiler-warning, #f59e0b)";
641
+ return "var(--profiler-success, #22c55e)";
642
+ }
643
+ function WaterfallView({ requests }) {
644
+ const timed = requests.filter((r3) => r3.started_at).map((r3) => ({ ...r3, startMs: new Date(r3.started_at).getTime() }));
645
+ if (!timed.length) {
646
+ return /* @__PURE__ */ u3("div", { class: "profiler-text--muted profiler-text--sm", children: "No timing data available (started_at missing)." });
647
+ }
648
+ const minStart = Math.min(...timed.map((r3) => r3.startMs));
649
+ const maxEnd = Math.max(...timed.map((r3) => r3.startMs + r3.duration));
650
+ const totalSpan = maxEnd - minStart || 1;
651
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((f4) => ({
652
+ pct: f4 * 100,
653
+ label: `${Math.round(f4 * totalSpan)}ms`
654
+ }));
655
+ return /* @__PURE__ */ u3("div", { children: [
656
+ /* @__PURE__ */ u3("div", { style: "display:flex;position:relative;margin-left:200px;margin-bottom:4px;height:16px", children: ticks.map((t3) => /* @__PURE__ */ u3("div", { style: `position:absolute;left:${t3.pct}%;font-size:10px;color:var(--profiler-text-muted);transform:translateX(-50%)`, children: t3.label }, t3.pct)) }),
657
+ /* @__PURE__ */ u3("div", { style: "position:relative", children: [
658
+ ticks.map((t3) => /* @__PURE__ */ u3("div", { style: `position:absolute;left:calc(200px + ${t3.pct}% * (100% - 200px) / 100);top:0;bottom:0;width:1px;background:var(--profiler-border);opacity:0.5;pointer-events:none` }, t3.pct)),
659
+ timed.map((req, i3) => {
660
+ const left = (req.startMs - minStart) / totalSpan * 100;
661
+ const width = Math.max(req.duration / totalSpan * 100, 0.5);
662
+ const path = req.url.replace(/^https?:\/\/[^/]+/, "") || req.url;
663
+ const color = waterfallBarColor(req.status, req.duration);
664
+ return /* @__PURE__ */ u3("div", { style: "display:flex;align-items:center;gap:0;margin-bottom:3px;height:22px", children: [
665
+ /* @__PURE__ */ u3(
666
+ "div",
667
+ {
668
+ title: req.url,
669
+ style: "width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--profiler-text-muted);padding-right:8px;text-align:right",
670
+ children: [
671
+ /* @__PURE__ */ u3("span", { style: `font-size:10px;font-weight:600;margin-right:4px;color:${color}`, children: req.method }),
672
+ path
673
+ ]
674
+ }
675
+ ),
676
+ /* @__PURE__ */ u3("div", { style: "flex:1;position:relative;height:14px;background:var(--profiler-bg-lighter);border-radius:2px", children: /* @__PURE__ */ u3(
677
+ "div",
678
+ {
679
+ style: `position:absolute;left:${left}%;width:${width}%;min-width:2px;height:100%;background:${color};border-radius:2px;opacity:0.85`,
680
+ title: `${req.duration.toFixed(2)}ms \xB7 ${req.status || "ERR"}`
681
+ }
682
+ ) }),
683
+ /* @__PURE__ */ u3("div", { style: "width:52px;text-align:right;font-size:11px;color:var(--profiler-text-muted);padding-left:6px;flex-shrink:0", children: [
684
+ req.duration.toFixed(0),
685
+ "ms"
686
+ ] })
687
+ ] }, i3);
688
+ })
689
+ ] }),
690
+ /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted", style: "margin-top:8px", children: [
691
+ "Total span: ",
692
+ totalSpan.toFixed(0),
693
+ "ms \xB7 ",
694
+ timed.length,
695
+ " request",
696
+ timed.length !== 1 ? "s" : ""
697
+ ] })
698
+ ] });
699
+ }
700
+ function buildCurl(req) {
701
+ const parts = [`curl -X ${req.method}`];
702
+ const headers = req.request_headers || {};
703
+ for (const [k3, v3] of Object.entries(headers)) {
704
+ parts.push(` -H ${JSON.stringify(`${k3}: ${v3}`)}`);
705
+ }
706
+ if (req.request_body && req.request_body_encoding !== "base64") {
707
+ parts.push(` -d ${JSON.stringify(req.request_body)}`);
708
+ }
709
+ parts.push(` ${JSON.stringify(req.url)}`);
710
+ return parts.join(" \\\n");
711
+ }
568
712
  function HttpRequestDetail({ req, index, threshold }) {
569
713
  const [open, setOpen] = d2(false);
714
+ const [copiedUrl, setCopiedUrl] = d2(false);
715
+ const [copiedCurl, setCopiedCurl] = d2(false);
570
716
  const isError = req.status >= 400 || req.status === 0;
571
717
  const isSlow = req.duration >= threshold;
572
718
  const cardCls = isError ? "profiler-ajax-card--error" : isSlow ? "profiler-ajax-card--warning" : "profiler-ajax-card--success";
719
+ function copyUrl(e3) {
720
+ e3.stopPropagation();
721
+ navigator.clipboard.writeText(req.url).then(() => {
722
+ setCopiedUrl(true);
723
+ setTimeout(() => setCopiedUrl(false), 2e3);
724
+ });
725
+ }
726
+ function copyCurl() {
727
+ navigator.clipboard.writeText(buildCurl(req)).then(() => {
728
+ setCopiedCurl(true);
729
+ setTimeout(() => setCopiedCurl(false), 2e3);
730
+ });
731
+ }
573
732
  return /* @__PURE__ */ u3("div", { class: `profiler-ajax-card ${cardCls}`, style: "margin-bottom:8px", children: [
574
733
  /* @__PURE__ */ u3(
575
734
  "div",
@@ -578,10 +737,20 @@
578
737
  style: "cursor:pointer;user-select:none",
579
738
  onClick: () => setOpen((o3) => !o3),
580
739
  children: [
581
- /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-3", children: [
740
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-3", style: "min-width:0;flex:1", children: [
582
741
  /* @__PURE__ */ u3("span", { style: "font-size:11px;color:var(--profiler-muted)", children: open ? "\u25BE" : "\u25B8" }),
583
742
  /* @__PURE__ */ u3("span", { class: `profiler-ajax-card__method badge-${methodBadge(req.method)}`, children: req.method }),
584
- /* @__PURE__ */ u3("strong", { class: "profiler-ajax-card__path", style: "word-break:break-all", children: req.url })
743
+ /* @__PURE__ */ u3("strong", { class: "profiler-ajax-card__path", style: "word-break:break-all", children: req.url }),
744
+ /* @__PURE__ */ u3(
745
+ "button",
746
+ {
747
+ onClick: copyUrl,
748
+ class: "profiler-body-download-btn profiler-text--xs",
749
+ style: "flex-shrink:0;cursor:pointer",
750
+ title: "Copy URL",
751
+ children: copiedUrl ? "Copied!" : "Copy URL"
752
+ }
753
+ )
585
754
  ] }),
586
755
  /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: "flex-shrink:0", children: [
587
756
  /* @__PURE__ */ u3("span", { class: `badge-${statusBadge(req.status)}`, children: req.status === 0 ? "ERR" : req.status }),
@@ -604,6 +773,7 @@
604
773
  ] }),
605
774
  req.error && /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--error", children: req.error }) }),
606
775
  open && /* @__PURE__ */ u3("div", { style: "padding:12px 4px 4px;border-top:1px solid rgba(0,0,0,0.08);margin-top:8px", children: [
776
+ /* @__PURE__ */ u3("div", { style: "margin-bottom:12px", children: /* @__PURE__ */ u3("button", { onClick: copyCurl, class: "profiler-body-download-btn profiler-text--xs", style: "cursor:pointer", children: copiedCurl ? "Copied!" : "Copy as cURL" }) }),
607
777
  /* @__PURE__ */ u3("div", { style: "margin-bottom:16px", children: [
608
778
  /* @__PURE__ */ u3("div", { class: "profiler-text--sm", style: "font-weight:600;margin-bottom:8px;color:var(--profiler-muted)", children: "REQUEST" }),
609
779
  /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted", style: "margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px", children: "Headers" }),
@@ -688,13 +858,19 @@
688
858
  ] })
689
859
  ] }),
690
860
  /* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mb-3", children: "Requests" }),
691
- /* @__PURE__ */ u3("p", { class: "profiler-text--xs profiler-text--muted profiler-mb-3", children: "Click a request to expand headers and body." }),
692
- httpData.requests.map((req, index) => /* @__PURE__ */ u3(HttpRequestDetail, { req, index, threshold }, index))
861
+ /* @__PURE__ */ u3(WaterfallView, { requests: httpData.requests }),
862
+ /* @__PURE__ */ u3("div", { style: "margin-top:16px", children: [
863
+ /* @__PURE__ */ u3("p", { class: "profiler-text--xs profiler-text--muted profiler-mb-3", children: "Click a request to expand headers and body." }),
864
+ httpData.requests.map((req, index) => /* @__PURE__ */ u3(HttpRequestDetail, { req, index, threshold }, index))
865
+ ] })
693
866
  ] });
694
867
  }
695
868
 
696
869
  // app/assets/typescript/profiler/components/ProfileList.tsx
697
870
  var BASE = "/_profiler";
871
+ function TableSkeleton({ cols, rows = 6 }) {
872
+ return /* @__PURE__ */ u3("div", { children: Array.from({ length: rows }).map((_2, i3) => /* @__PURE__ */ u3("div", { class: "profiler-skeleton__row", children: cols.map((size, j3) => /* @__PURE__ */ u3("div", { class: `profiler-skeleton__cell profiler-skeleton__cell--${size}` }, j3)) }, i3)) });
873
+ }
698
874
  function methodClass(method) {
699
875
  const map = { GET: "badge-info", POST: "badge-success", PUT: "badge-warning", PATCH: "badge-warning", DELETE: "badge-error" };
700
876
  return map[method] || "badge-default";
@@ -734,7 +910,7 @@
734
910
  const col = params.get("sort");
735
911
  const dir = params.get("dir");
736
912
  return {
737
- col: col === "duration" || col === "memory" || col === "status" || col === "queries" ? col : null,
913
+ col: col === "date" || col === "duration" || col === "memory" || col === "status" || col === "queries" ? col : null,
738
914
  dir: dir === "desc" ? "desc" : "asc"
739
915
  };
740
916
  };
@@ -766,6 +942,7 @@
766
942
  return PRESETS.some((pr) => pr.key === p3) ? p3 : "";
767
943
  });
768
944
  const [httpSort, setHttpSort] = d2(initialSort);
945
+ const [jobSort, setJobSort] = d2({ col: null, dir: "asc" });
769
946
  const [jobSearch, setJobSearch] = d2("");
770
947
  const [jobStatus, setJobStatus] = d2("");
771
948
  const [jobDuration, setJobDuration] = d2("");
@@ -808,6 +985,11 @@
808
985
  (prev) => prev.col === col ? { col, dir: prev.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }
809
986
  );
810
987
  };
988
+ const toggleJobSort = (col) => {
989
+ setJobSort(
990
+ (prev) => prev.col === col ? { col, dir: prev.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }
991
+ );
992
+ };
811
993
  const togglePreset = (key) => {
812
994
  setHttpPreset((prev) => prev === key ? "" : key);
813
995
  };
@@ -873,6 +1055,7 @@
873
1055
  setJobSearch("");
874
1056
  setJobStatus("");
875
1057
  setJobDuration("");
1058
+ setJobSort({ col: null, dir: "asc" });
876
1059
  setOutboundSearch("");
877
1060
  setOutboundMethod("");
878
1061
  setOutboundStatus("");
@@ -955,6 +1138,10 @@
955
1138
  return true;
956
1139
  });
957
1140
  const sortedProfiles = httpSort.col ? [...filteredProfiles].sort((a3, b) => {
1141
+ if (httpSort.col === "date") {
1142
+ const diff = new Date(a3.started_at).getTime() - new Date(b.started_at).getTime();
1143
+ return httpSort.dir === "asc" ? diff : -diff;
1144
+ }
958
1145
  let av, bv;
959
1146
  switch (httpSort.col) {
960
1147
  case "duration":
@@ -987,6 +1174,26 @@
987
1174
  }
988
1175
  return true;
989
1176
  });
1177
+ const sortedJobs = jobSort.col ? [...filteredJobs].sort((a3, b) => {
1178
+ if (jobSort.col === "date") {
1179
+ const diff = new Date(a3.started_at).getTime() - new Date(b.started_at).getTime();
1180
+ return jobSort.dir === "asc" ? diff : -diff;
1181
+ }
1182
+ let av, bv;
1183
+ switch (jobSort.col) {
1184
+ case "duration":
1185
+ av = a3.duration;
1186
+ bv = b.duration;
1187
+ break;
1188
+ case "status":
1189
+ av = a3.status;
1190
+ bv = b.status;
1191
+ break;
1192
+ default:
1193
+ return 0;
1194
+ }
1195
+ return jobSort.dir === "asc" ? av - bv : bv - av;
1196
+ }) : filteredJobs;
990
1197
  const filteredOutbound = outboundRequests.filter((req) => {
991
1198
  if (outboundSearch && !req.url.toLowerCase().includes(outboundSearch.toLowerCase())) return false;
992
1199
  if (outboundMethod && req.method !== outboundMethod) return false;
@@ -1003,9 +1210,9 @@
1003
1210
  const httpFiltersActive = !!(httpSearch || httpMethod || httpStatus || httpDuration || httpPreset);
1004
1211
  const jobFiltersActive = !!(jobSearch || jobStatus || jobDuration);
1005
1212
  const outboundFiltersActive = !!(outboundSearch || outboundMethod || outboundStatus);
1006
- const sortIcon = (col) => {
1007
- if (httpSort.col !== col) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
1008
- return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: httpSort.dir === "asc" ? "\u25B2" : "\u25BC" });
1213
+ const sortIcon = (activeCol, dir, col) => {
1214
+ if (activeCol !== col) return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--idle", children: "\u21C5" });
1215
+ return /* @__PURE__ */ u3("span", { class: "sort-icon sort-icon--active", children: dir === "asc" ? "\u25B2" : "\u25BC" });
1009
1216
  };
1010
1217
  return /* @__PURE__ */ u3("div", { class: "container", children: [
1011
1218
  /* @__PURE__ */ u3("div", { class: "header", children: [
@@ -1031,7 +1238,7 @@
1031
1238
  }, children: "Outbound HTTP" })
1032
1239
  ] }),
1033
1240
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
1034
- section === "http" && (loadingHttp ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "Loading..." }) }) : error ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: error }) }) : profiles.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1241
+ section === "http" && (loadingHttp ? /* @__PURE__ */ u3(TableSkeleton, { cols: ["sm", "xs", "flex", "sm", "sm", "sm", "xs", "sm"] }) : error ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: error }) }) : profiles.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1035
1242
  /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No profiles found" }),
1036
1243
  /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Make some requests to your application to see profiling data" })
1037
1244
  ] }) : /* @__PURE__ */ u3(k, { children: [
@@ -1090,24 +1297,27 @@
1090
1297
  ] }),
1091
1298
  filteredProfiles.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No results match filters" }) }) : /* @__PURE__ */ u3("table", { children: [
1092
1299
  /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
1093
- /* @__PURE__ */ u3("th", { children: "Time" }),
1300
+ /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "date" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("date"), children: [
1301
+ "Time ",
1302
+ sortIcon(httpSort.col, httpSort.dir, "date")
1303
+ ] }),
1094
1304
  /* @__PURE__ */ u3("th", { children: "Method" }),
1095
1305
  /* @__PURE__ */ u3("th", { children: "Path" }),
1096
1306
  /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "duration" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("duration"), children: [
1097
1307
  "Duration ",
1098
- sortIcon("duration")
1308
+ sortIcon(httpSort.col, httpSort.dir, "duration")
1099
1309
  ] }),
1100
1310
  /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "queries" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("queries"), children: [
1101
1311
  "Queries ",
1102
- sortIcon("queries")
1312
+ sortIcon(httpSort.col, httpSort.dir, "queries")
1103
1313
  ] }),
1104
1314
  /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "memory" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("memory"), children: [
1105
1315
  "Memory ",
1106
- sortIcon("memory")
1316
+ sortIcon(httpSort.col, httpSort.dir, "memory")
1107
1317
  ] }),
1108
1318
  /* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "status" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("status"), children: [
1109
1319
  "Status ",
1110
- sortIcon("status")
1320
+ sortIcon(httpSort.col, httpSort.dir, "status")
1111
1321
  ] }),
1112
1322
  /* @__PURE__ */ u3("th", { children: "Token" }),
1113
1323
  /* @__PURE__ */ u3("th", {})
@@ -1129,7 +1339,7 @@
1129
1339
  ] }),
1130
1340
  httpHasMore && !httpFiltersActive && /* @__PURE__ */ u3("div", { class: "profiler-load-more", children: /* @__PURE__ */ u3("button", { class: "btn btn-secondary", onClick: loadMoreHttp, disabled: httpLoadingMore, children: httpLoadingMore ? "Loading\u2026" : "Load more" }) })
1131
1341
  ] })),
1132
- section === "jobs" && (loadingJobs ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "Loading..." }) }) : jobsError ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: jobsError }) }) : jobs.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1342
+ section === "jobs" && (loadingJobs ? /* @__PURE__ */ u3(TableSkeleton, { cols: ["sm", "flex", "md", "sm", "xs", "xs", "sm"] }) : jobsError ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: jobsError }) }) : jobs.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1133
1343
  /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No job profiles found" }),
1134
1344
  /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Run background jobs in your application to see profiling data" })
1135
1345
  ] }) : /* @__PURE__ */ u3(k, { children: [
@@ -1169,16 +1379,25 @@
1169
1379
  ] }),
1170
1380
  filteredJobs.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No results match filters" }) }) : /* @__PURE__ */ u3("table", { children: [
1171
1381
  /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
1172
- /* @__PURE__ */ u3("th", { children: "Time" }),
1382
+ /* @__PURE__ */ u3("th", { class: `sortable${jobSort.col === "date" ? " sortable--active" : ""}`, onClick: () => toggleJobSort("date"), children: [
1383
+ "Time ",
1384
+ sortIcon(jobSort.col, jobSort.dir, "date")
1385
+ ] }),
1173
1386
  /* @__PURE__ */ u3("th", { children: "Job Class" }),
1174
1387
  /* @__PURE__ */ u3("th", { children: "Queue" }),
1175
- /* @__PURE__ */ u3("th", { children: "Duration" }),
1176
- /* @__PURE__ */ u3("th", { children: "Status" }),
1388
+ /* @__PURE__ */ u3("th", { class: `sortable${jobSort.col === "duration" ? " sortable--active" : ""}`, onClick: () => toggleJobSort("duration"), children: [
1389
+ "Duration ",
1390
+ sortIcon(jobSort.col, jobSort.dir, "duration")
1391
+ ] }),
1392
+ /* @__PURE__ */ u3("th", { class: `sortable${jobSort.col === "status" ? " sortable--active" : ""}`, onClick: () => toggleJobSort("status"), children: [
1393
+ "Status ",
1394
+ sortIcon(jobSort.col, jobSort.dir, "status")
1395
+ ] }),
1177
1396
  /* @__PURE__ */ u3("th", { children: "Executions" }),
1178
1397
  /* @__PURE__ */ u3("th", { children: "Token" }),
1179
1398
  /* @__PURE__ */ u3("th", {})
1180
1399
  ] }) }),
1181
- /* @__PURE__ */ u3("tbody", { children: filteredJobs.map((p3) => {
1400
+ /* @__PURE__ */ u3("tbody", { children: sortedJobs.map((p3) => {
1182
1401
  const jobData = p3.collectors_data?.job;
1183
1402
  const isFailed = p3.status === 500;
1184
1403
  return /* @__PURE__ */ u3("tr", { children: [
@@ -1198,7 +1417,7 @@
1198
1417
  ] }),
1199
1418
  jobHasMore && !jobFiltersActive && /* @__PURE__ */ u3("div", { class: "profiler-load-more", children: /* @__PURE__ */ u3("button", { class: "btn btn-secondary", onClick: loadMoreJobs, disabled: jobLoadingMore, children: jobLoadingMore ? "Loading\u2026" : "Load more" }) })
1200
1419
  ] })),
1201
- section === "outbound" && (loadingOutbound ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "Loading..." }) }) : outboundError ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: outboundError }) }) : outboundRequests.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1420
+ section === "outbound" && (loadingOutbound ? /* @__PURE__ */ u3(TableSkeleton, { cols: ["sm", "xs", "flex", "sm", "xs"], rows: 4 }) : outboundError ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: outboundError }) }) : outboundRequests.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
1202
1421
  /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No outbound HTTP requests found" }),
1203
1422
  /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Make requests to external services to see outbound HTTP data" })
1204
1423
  ] }) : /* @__PURE__ */ u3(k, { children: [
@@ -1264,22 +1483,7 @@
1264
1483
  }
1265
1484
 
1266
1485
  // app/assets/typescript/profiler/components/dashboard/tabs/RequestTab.tsx
1267
- function tryFormatJson(text) {
1268
- try {
1269
- return JSON.stringify(JSON.parse(text), null, 2);
1270
- } catch {
1271
- return text;
1272
- }
1273
- }
1274
- function BodyBlock({ body, encoding }) {
1275
- if (!body) return null;
1276
- if (encoding === "base64") {
1277
- return /* @__PURE__ */ u3("p", { class: "profiler-text--muted profiler-text--sm", children: "[binary content, base64-encoded]" });
1278
- }
1279
- const formatted = tryFormatJson(body);
1280
- return /* @__PURE__ */ u3("pre", { class: "profiler-code profiler-text--xs", children: formatted });
1281
- }
1282
- function buildCurl(profile) {
1486
+ function buildCurl2(profile) {
1283
1487
  const headers = profile.headers ?? {};
1284
1488
  const params = profile.params ?? {};
1285
1489
  const reqBody = profile.request_body;
@@ -1308,7 +1512,7 @@
1308
1512
  }
1309
1513
  function RequestTab({ profile }) {
1310
1514
  const [copied, setCopied] = d2(false);
1311
- const curl = buildCurl(profile);
1515
+ const curl = buildCurl2(profile);
1312
1516
  const routeData = profile.collectors_data?.request ?? {};
1313
1517
  function copyToClipboard() {
1314
1518
  navigator.clipboard.writeText(curl).then(() => {
@@ -1368,7 +1572,14 @@
1368
1572
  ] }),
1369
1573
  profile.request_body && /* @__PURE__ */ u3(k, { children: [
1370
1574
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header profiler-mt-6", children: "Request Body" }),
1371
- /* @__PURE__ */ u3(BodyBlock, { body: profile.request_body, encoding: profile.request_body_encoding })
1575
+ /* @__PURE__ */ u3(
1576
+ SmartBodyPreview,
1577
+ {
1578
+ body: profile.request_body,
1579
+ encoding: profile.request_body_encoding,
1580
+ headers: profile.headers ?? {}
1581
+ }
1582
+ )
1372
1583
  ] }),
1373
1584
  profile.response_headers && Object.keys(profile.response_headers).length > 0 && /* @__PURE__ */ u3(k, { children: [
1374
1585
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header profiler-mt-6", children: "Response Headers" }),
@@ -1379,7 +1590,14 @@
1379
1590
  ] }),
1380
1591
  profile.response_body && /* @__PURE__ */ u3(k, { children: [
1381
1592
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header profiler-mt-6", children: "Response Body" }),
1382
- /* @__PURE__ */ u3(BodyBlock, { body: profile.response_body, encoding: profile.response_body_encoding })
1593
+ /* @__PURE__ */ u3(
1594
+ SmartBodyPreview,
1595
+ {
1596
+ body: profile.response_body,
1597
+ encoding: profile.response_body_encoding,
1598
+ headers: profile.response_headers ?? {}
1599
+ }
1600
+ )
1383
1601
  ] }),
1384
1602
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header profiler-mt-6", children: "Curl Command" }),
1385
1603
  /* @__PURE__ */ u3("div", { style: "position: relative;", children: [
@@ -1461,6 +1679,14 @@
1461
1679
  ] });
1462
1680
  }
1463
1681
  function ExplainModal({ state, onClose }) {
1682
+ y2(() => {
1683
+ if (!state.open) return;
1684
+ const handler = (e3) => {
1685
+ if (e3.key === "Escape") onClose();
1686
+ };
1687
+ document.addEventListener("keydown", handler);
1688
+ return () => document.removeEventListener("keydown", handler);
1689
+ }, [state.open]);
1464
1690
  if (!state.open) return null;
1465
1691
  const renderResult = () => {
1466
1692
  if (state.loading) return /* @__PURE__ */ u3("div", { class: "profiler-text--muted", children: "Running EXPLAIN ANALYZE\u2026" });
@@ -1571,7 +1797,7 @@
1571
1797
  ] }),
1572
1798
  n1Groups.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
1573
1799
  /* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
1574
- /* @__PURE__ */ u3("div", { children: [
1800
+ /* @__PURE__ */ u3("div", { style: "flex:1", children: [
1575
1801
  /* @__PURE__ */ u3("strong", { children: "Potential N+1 detected" }),
1576
1802
  " \u2014 ",
1577
1803
  n1Groups.length,
@@ -1581,7 +1807,19 @@
1581
1807
  n1Groups.reduce((sum, g2) => sum + g2.indices.length, 0),
1582
1808
  " times total",
1583
1809
  /* @__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." })
1584
- ] })
1810
+ ] }),
1811
+ n1Groups.length > 1 && /* @__PURE__ */ u3(
1812
+ "button",
1813
+ {
1814
+ class: "profiler-btn profiler-btn--sm",
1815
+ style: "flex-shrink:0;align-self:flex-start",
1816
+ onClick: () => {
1817
+ const allOpen = n1Groups.every((g2) => openBacktraces.has(g2.pattern));
1818
+ setOpenBacktraces(allOpen ? /* @__PURE__ */ new Set() : new Set(n1Groups.map((g2) => g2.pattern)));
1819
+ },
1820
+ children: n1Groups.every((g2) => openBacktraces.has(g2.pattern)) ? "Collapse all" : "Expand all"
1821
+ }
1822
+ )
1585
1823
  ] }),
1586
1824
  n1Groups.map((group) => /* @__PURE__ */ u3("div", { class: "profiler-n1-group profiler-mb-4", children: [
1587
1825
  /* @__PURE__ */ u3("div", { class: "profiler-n1-group__header", onClick: () => toggleBacktrace(group.pattern), children: [
@@ -3035,6 +3273,7 @@
3035
3273
  }
3036
3274
  function LogsTab({ logData }) {
3037
3275
  const [filter, setFilter] = d2("ALL");
3276
+ const [search, setSearch] = d2("");
3038
3277
  if (!logData?.logs?.length) {
3039
3278
  return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
3040
3279
  /* @__PURE__ */ u3("div", { class: "profiler-empty__icon", children: "\u{1F4CB}" }),
@@ -3047,7 +3286,11 @@
3047
3286
  ] });
3048
3287
  }
3049
3288
  const levels = ["ALL", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"];
3050
- const filtered = filter === "ALL" ? logData.logs : logData.logs.filter((l3) => l3.level === filter);
3289
+ const filtered = logData.logs.filter((l3) => {
3290
+ if (filter !== "ALL" && l3.level !== filter) return false;
3291
+ if (search && !l3.message.toLowerCase().includes(search.toLowerCase())) return false;
3292
+ return true;
3293
+ });
3051
3294
  return /* @__PURE__ */ u3(k, { children: [
3052
3295
  /* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
3053
3296
  "Log Messages (",
@@ -3064,19 +3307,32 @@
3064
3307
  /* @__PURE__ */ u3("strong", { class: "profiler-text--warning", children: logData.warnings })
3065
3308
  ] })
3066
3309
  ] }),
3067
- /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mb-4", children: levels.map((level) => /* @__PURE__ */ u3(
3068
- "button",
3069
- {
3070
- onClick: () => setFilter(level),
3071
- class: `btn btn-sm ${filter === level ? "btn-primary" : "btn-secondary"}`,
3072
- children: level
3073
- },
3074
- level
3075
- )) }),
3310
+ /* @__PURE__ */ u3("div", { class: "profiler-action-bar profiler-mb-4", children: [
3311
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: levels.map((level) => /* @__PURE__ */ u3(
3312
+ "button",
3313
+ {
3314
+ onClick: () => setFilter(level),
3315
+ class: `btn btn-sm ${filter === level ? "btn-primary" : "btn-secondary"}`,
3316
+ children: level
3317
+ },
3318
+ level
3319
+ )) }),
3320
+ /* @__PURE__ */ u3(
3321
+ "input",
3322
+ {
3323
+ type: "text",
3324
+ class: "profiler-filter-input",
3325
+ placeholder: "Search messages\u2026",
3326
+ value: search,
3327
+ onInput: (e3) => setSearch(e3.target.value),
3328
+ style: "width:200px"
3329
+ }
3330
+ )
3331
+ ] }),
3076
3332
  filtered.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-text--muted profiler-text--sm", children: [
3077
- "No ",
3078
- filter,
3079
- " messages."
3333
+ "No messages match",
3334
+ search ? ` "${search}"` : "",
3335
+ "."
3080
3336
  ] }) : filtered.map((entry, index) => /* @__PURE__ */ u3("div", { class: "profiler-query-card profiler-mb-2", children: [
3081
3337
  /* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
3082
3338
  /* @__PURE__ */ u3(
@@ -3321,6 +3577,47 @@
3321
3577
  ] });
3322
3578
  }
3323
3579
 
3580
+ // app/assets/typescript/profiler/components/dashboard/tabs/JobsTab.tsx
3581
+ function JobsTab({ jobs }) {
3582
+ if (!jobs?.length) {
3583
+ return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
3584
+ /* @__PURE__ */ u3("div", { class: "profiler-empty__icon", children: "\u2699\uFE0F" }),
3585
+ /* @__PURE__ */ u3("h3", { class: "profiler-empty__title", children: "No jobs triggered" }),
3586
+ /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "Background jobs enqueued during this request will appear here." })
3587
+ ] });
3588
+ }
3589
+ return /* @__PURE__ */ u3(k, { children: [
3590
+ /* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
3591
+ "Background Jobs (",
3592
+ jobs.length,
3593
+ ")"
3594
+ ] }),
3595
+ jobs.map((job, index) => /* @__PURE__ */ u3("div", { class: `profiler-ajax-card profiler-ajax-card--${job.status === "completed" ? "success" : job.status === "failed" ? "error" : "default"}`, children: [
3596
+ /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
3597
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-3", children: [
3598
+ /* @__PURE__ */ u3("span", { class: `badge-${job.status === "completed" ? "success" : job.status === "failed" ? "error" : "warning"}`, children: job.status ?? "unknown" }),
3599
+ /* @__PURE__ */ u3("strong", { class: "profiler-ajax-card__path", children: job.job_class })
3600
+ ] }),
3601
+ /* @__PURE__ */ u3("span", { class: job.duration >= 1e3 ? "badge-error" : job.duration >= 200 ? "badge-warning" : "badge-success", children: [
3602
+ job.duration?.toFixed(2),
3603
+ " ms"
3604
+ ] })
3605
+ ] }),
3606
+ /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
3607
+ /* @__PURE__ */ u3("span", { class: "profiler-ajax-card__time profiler-text--muted", children: [
3608
+ job.queue && /* @__PURE__ */ u3("span", { children: [
3609
+ "Queue: ",
3610
+ /* @__PURE__ */ u3("strong", { children: job.queue }),
3611
+ " \xB7 "
3612
+ ] }),
3613
+ new Date(job.started_at).toLocaleTimeString("en", { hour12: false })
3614
+ ] }),
3615
+ /* @__PURE__ */ u3("a", { href: `/_profiler/profiles/${job.token}`, class: "profiler-text--sm", style: "color: var(--profiler-accent);", children: "View Job \u2192" })
3616
+ ] })
3617
+ ] }, index))
3618
+ ] });
3619
+ }
3620
+
3324
3621
  // app/assets/typescript/profiler/components/dashboard/ProfileDashboard.tsx
3325
3622
  function ProfileDashboard({ profile, initialTab, embedded }) {
3326
3623
  const cd = profile.collectors_data || {};
@@ -3330,6 +3627,7 @@
3330
3627
  const hasLogs = (cd["logs"]?.count ?? 0) > 0;
3331
3628
  const hasRoutes = (cd["routes"]?.total ?? 0) > 0;
3332
3629
  const hasI18n = (cd["i18n"]?.total ?? 0) > 0;
3630
+ const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
3333
3631
  const [activeTab, setActiveTab] = d2(hasException ? "exception" : initialTab);
3334
3632
  const handleTabClick = (tab) => (e3) => {
3335
3633
  e3.preventDefault();
@@ -3368,7 +3666,8 @@
3368
3666
  (profile.memory / 1024 / 1024).toFixed(2),
3369
3667
  " MB"
3370
3668
  ] })
3371
- ] })
3669
+ ] }),
3670
+ /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted)", children: new Date(profile.started_at).toLocaleString("en", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) })
3372
3671
  ] })
3373
3672
  ] }),
3374
3673
  /* @__PURE__ */ u3("div", { class: "profiler-panel profiler-mb-6", children: [
@@ -3384,7 +3683,12 @@
3384
3683
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("cache"), onClick: handleTabClick("cache"), children: "Cache" }),
3385
3684
  hasLogs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("logs"), onClick: handleTabClick("logs"), children: "Logs" }),
3386
3685
  hasRoutes && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("routes"), onClick: handleTabClick("routes"), children: "Routes" }),
3387
- hasI18n && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("i18n"), onClick: handleTabClick("i18n"), children: "I18n" })
3686
+ hasI18n && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("i18n"), onClick: handleTabClick("i18n"), children: "I18n" }),
3687
+ hasJobs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("jobs"), onClick: handleTabClick("jobs"), children: [
3688
+ "Jobs (",
3689
+ profile.child_jobs.length,
3690
+ ")"
3691
+ ] })
3388
3692
  ] }),
3389
3693
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
3390
3694
  activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
@@ -3398,7 +3702,8 @@
3398
3702
  activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
3399
3703
  activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
3400
3704
  activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] }),
3401
- activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] })
3705
+ activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] }),
3706
+ activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs })
3402
3707
  ] })
3403
3708
  ] }),
3404
3709
  !embedded && /* @__PURE__ */ u3("div", { class: "profiler-mt-6", children: /* @__PURE__ */ u3("a", { href: "/_profiler", style: "color: var(--profiler-accent);", children: "\u2190 Back to profiles" }) })
@@ -3461,13 +3766,15 @@
3461
3766
 
3462
3767
  // app/assets/typescript/profiler/components/dashboard/JobProfileDashboard.tsx
3463
3768
  function JobProfileDashboard({ profile, initialTab, embedded }) {
3464
- const validTabs = ["job", "database", "cache", "http"];
3769
+ const validTabs = ["job", "database", "cache", "http", "jobs"];
3465
3770
  const defaultTab = validTabs.includes(initialTab) ? initialTab : "job";
3466
3771
  const [activeTab, setActiveTab] = d2(defaultTab);
3467
3772
  const cd = profile.collectors_data || {};
3468
3773
  const hasHttp = cd["http"]?.total_requests > 0;
3774
+ const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
3469
3775
  const jobData = cd["job"];
3470
3776
  const isFailed = jobData?.status === "failed";
3777
+ const parent = profile.parent_profile;
3471
3778
  const handleTabClick = (tab) => (e3) => {
3472
3779
  e3.preventDefault();
3473
3780
  setActiveTab(tab);
@@ -3502,6 +3809,26 @@
3502
3809
  " MB"
3503
3810
  ] })
3504
3811
  ] })
3812
+ ] }),
3813
+ parent && /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mt-2 profiler-text--sm", children: [
3814
+ /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted)", children: "Triggered by:" }),
3815
+ parent.profile_type === "http" ? /* @__PURE__ */ u3("span", { children: [
3816
+ /* @__PURE__ */ u3("strong", { children: parent.method }),
3817
+ " ",
3818
+ parent.path,
3819
+ " \xB7 ",
3820
+ parent.http_status,
3821
+ " \xB7 ",
3822
+ parent.duration?.toFixed(2),
3823
+ " ms"
3824
+ ] }) : /* @__PURE__ */ u3("span", { children: [
3825
+ "\u2699\uFE0F ",
3826
+ /* @__PURE__ */ u3("strong", { children: parent.path }),
3827
+ " \xB7 ",
3828
+ parent.duration?.toFixed(2),
3829
+ " ms"
3830
+ ] }),
3831
+ /* @__PURE__ */ u3("a", { href: `/_profiler/profiles/${parent.token}`, style: "color: var(--profiler-accent);", children: "View \u2192" })
3505
3832
  ] })
3506
3833
  ] }),
3507
3834
  /* @__PURE__ */ u3("div", { class: "profiler-panel profiler-mb-6", children: [
@@ -3509,13 +3836,19 @@
3509
3836
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("job"), onClick: handleTabClick("job"), children: "Job" }),
3510
3837
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("database"), onClick: handleTabClick("database"), children: "Database" }),
3511
3838
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("cache"), onClick: handleTabClick("cache"), children: "Cache" }),
3512
- hasHttp && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("http"), onClick: handleTabClick("http"), children: "Outbound HTTP" })
3839
+ hasHttp && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("http"), onClick: handleTabClick("http"), children: "Outbound HTTP" }),
3840
+ hasJobs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("jobs"), onClick: handleTabClick("jobs"), children: [
3841
+ "Jobs (",
3842
+ profile.child_jobs.length,
3843
+ ")"
3844
+ ] })
3513
3845
  ] }),
3514
3846
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
3515
3847
  activeTab === "job" && /* @__PURE__ */ u3(JobTab, { jobData: cd["job"] }),
3516
3848
  activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
3517
3849
  activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
3518
- activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] })
3850
+ activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
3851
+ activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs })
3519
3852
  ] })
3520
3853
  ] }),
3521
3854
  !embedded && /* @__PURE__ */ u3("div", { class: "profiler-mt-6", children: /* @__PURE__ */ u3("a", { href: "/_profiler", style: "color: var(--profiler-accent);", children: "\u2190 Back to profiles" }) })
@@ -26,7 +26,10 @@ module Profiler
26
26
  return render json: { error: "Job profile not found" }, status: :not_found
27
27
  end
28
28
 
29
- render json: profile.to_h
29
+ render json: profile.to_h.merge(
30
+ child_jobs: build_child_jobs(profile),
31
+ parent_profile: build_parent_summary(profile)
32
+ )
30
33
  end
31
34
 
32
35
  def destroy
@@ -29,7 +29,10 @@ module Profiler
29
29
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
30
30
  recalculate_ajax_data(profile)
31
31
 
32
- render json: profile.to_h
32
+ render json: profile.to_h.merge(
33
+ child_jobs: build_child_jobs(profile),
34
+ parent_profile: build_parent_summary(profile)
35
+ )
33
36
  end
34
37
 
35
38
  def destroy
@@ -15,5 +15,51 @@ module Profiler
15
15
  render plain: "Profiler is disabled", status: :forbidden
16
16
  end
17
17
  end
18
+
19
+ def build_child_jobs(profile)
20
+ Profiler.storage.find_by_parent(profile.token)
21
+ .select { |p| p.profile_type == "job" }
22
+ .map do |j|
23
+ job_data = j.collector_data("job") || {}
24
+ {
25
+ token: j.token,
26
+ job_class: j.path,
27
+ job_id: job_data["job_id"],
28
+ queue: job_data["queue"],
29
+ status: job_data["status"],
30
+ duration: j.duration,
31
+ started_at: j.started_at&.iso8601
32
+ }
33
+ end
34
+ end
35
+
36
+ def build_parent_summary(profile)
37
+ return nil unless profile.parent_token
38
+
39
+ parent = Profiler.storage.load(profile.parent_token)
40
+ return nil unless parent
41
+
42
+ if parent.profile_type == "job"
43
+ job_data = parent.collector_data("job") || {}
44
+ {
45
+ token: parent.token,
46
+ profile_type: "job",
47
+ path: parent.path,
48
+ status: job_data["status"],
49
+ duration: parent.duration,
50
+ started_at: parent.started_at&.iso8601
51
+ }
52
+ else
53
+ {
54
+ token: parent.token,
55
+ profile_type: "http",
56
+ method: parent.method,
57
+ path: parent.path,
58
+ http_status: parent.status,
59
+ duration: parent.duration,
60
+ started_at: parent.started_at&.iso8601
61
+ }
62
+ end
63
+ end
18
64
  end
19
65
  end
@@ -23,6 +23,11 @@ module Profiler
23
23
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
24
24
  recalculate_ajax_data(@profile)
25
25
 
26
+ @profile_data = @profile.to_h.merge(
27
+ child_jobs: build_child_jobs(@profile),
28
+ parent_profile: build_parent_summary(@profile)
29
+ )
30
+
26
31
  @embedded = params[:embed] == "true"
27
32
 
28
33
  render layout: @embedded ? "profiler/embedded" : "profiler/application"
@@ -1,4 +1,4 @@
1
1
  <div id="profiler-show" data-embedded="<%= @embedded %>"></div>
2
2
  <script type="application/json" id="profiler-show-data">
3
- <%= @profile.to_json.html_safe %>
3
+ <%= @profile_data.to_json.html_safe %>
4
4
  </script>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module CurrentContext
5
+ def self.token
6
+ Thread.current[:profiler_token]
7
+ end
8
+
9
+ def self.token=(value)
10
+ Thread.current[:profiler_token] = value
11
+ end
12
+
13
+ def self.clear
14
+ Thread.current[:profiler_token] = nil
15
+ end
16
+ end
17
+ end
@@ -6,6 +6,12 @@ module Profiler
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
+ attr_accessor :profiler_parent_token
10
+
11
+ before_enqueue do |job|
12
+ job.profiler_parent_token = Profiler::CurrentContext.token
13
+ end
14
+
9
15
  around_perform do |job, block|
10
16
  Profiler::JobProfiler.profile(
11
17
  job_class: job.class.name,
@@ -13,10 +19,20 @@ module Profiler
13
19
  queue: job.queue_name,
14
20
  arguments: job.arguments,
15
21
  executions: job.executions - 1,
22
+ parent_token: job.profiler_parent_token,
16
23
  &block
17
24
  )
18
25
  end
19
26
  end
27
+
28
+ def serialize
29
+ super.merge("profiler_parent_token" => profiler_parent_token)
30
+ end
31
+
32
+ def deserialize(job_data)
33
+ super
34
+ self.profiler_parent_token = job_data["profiler_parent_token"]
35
+ end
20
36
  end
21
37
  end
22
38
  end
@@ -2,6 +2,13 @@
2
2
 
3
3
  module Profiler
4
4
  module Instrumentation
5
+ class SidekiqClientMiddleware
6
+ def call(_worker_class, job, _queue, _redis_pool)
7
+ job["profiler_parent_token"] = Profiler::CurrentContext.token
8
+ yield
9
+ end
10
+ end
11
+
5
12
  class SidekiqMiddleware
6
13
  def call(worker, job, queue, &block)
7
14
  Profiler::JobProfiler.profile(
@@ -10,6 +17,7 @@ module Profiler
10
17
  queue: queue,
11
18
  arguments: job["args"],
12
19
  executions: job["retry_count"].to_i,
20
+ parent_token: job["profiler_parent_token"],
13
21
  &block
14
22
  )
15
23
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "models/profile"
4
+ require_relative "current_context"
4
5
  require_relative "collectors/job_collector"
5
6
  require_relative "collectors/database_collector"
6
7
  require_relative "collectors/cache_collector"
@@ -14,7 +15,7 @@ module Profiler
14
15
  Collectors::HttpCollector
15
16
  ].freeze
16
17
 
17
- def self.profile(job_class:, job_id:, queue:, arguments:, executions:, &block)
18
+ def self.profile(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil, &block)
18
19
  return block.call unless Profiler.enabled? && Profiler.configuration.track_jobs
19
20
 
20
21
  new(
@@ -22,16 +23,18 @@ module Profiler
22
23
  job_id: job_id,
23
24
  queue: queue,
24
25
  arguments: arguments,
25
- executions: executions
26
+ executions: executions,
27
+ parent_token: parent_token
26
28
  ).run(&block)
27
29
  end
28
30
 
29
- def initialize(job_class:, job_id:, queue:, arguments:, executions:)
31
+ def initialize(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil)
30
32
  @job_class = job_class
31
33
  @job_id = job_id
32
34
  @queue = queue
33
35
  @arguments = arguments
34
36
  @executions = executions
37
+ @parent_token = parent_token
35
38
  end
36
39
 
37
40
  def run(&block)
@@ -39,6 +42,7 @@ module Profiler
39
42
  profile.profile_type = "job"
40
43
  profile.path = @job_class
41
44
  profile.method = "JOB"
45
+ profile.parent_token = @parent_token if @parent_token
42
46
 
43
47
  job_collector = Collectors::JobCollector.new(profile, {
44
48
  job_class: @job_class,
@@ -56,6 +60,8 @@ module Profiler
56
60
  job_status = "completed"
57
61
  error_message = nil
58
62
 
63
+ previous_token = Profiler::CurrentContext.token
64
+ Profiler::CurrentContext.token = profile.token
59
65
  begin
60
66
  result = block.call
61
67
  result
@@ -64,6 +70,7 @@ module Profiler
64
70
  error_message = "#{e.class}: #{e.message}"
65
71
  raise
66
72
  ensure
73
+ Profiler::CurrentContext.token = previous_token
67
74
  if Profiler.configuration.track_memory
68
75
  profile.memory = current_memory - memory_before
69
76
  end
@@ -63,17 +63,21 @@ module Profiler
63
63
  lines += section_http(profile) if want.("http")
64
64
  lines += section_routes(profile) if want.("routes")
65
65
  lines += section_dumps(profile) if want.("dumps")
66
+ lines += section_related_jobs(profile) if want.("related_jobs")
66
67
  lines.join("\n")
67
68
  end
68
69
 
69
70
  def self.section_overview(profile)
70
71
  lines = []
71
72
  lines << "# Profile Details: #{profile.token}\n"
73
+ lines << "**Type:** #{profile.profile_type == 'job' ? 'Job' : 'HTTP Request'}"
72
74
  lines << "**Request:** #{profile.method} #{profile.path}"
73
75
  lines << "**Status:** #{profile.status}"
74
76
  lines << "**Duration:** #{profile.duration.round(2)} ms"
75
77
  lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
76
- lines << "**Time:** #{profile.started_at}\n"
78
+ lines << "**Time:** #{profile.started_at}"
79
+ lines << "**Parent Token:** #{profile.parent_token}" if profile.parent_token
80
+ lines << ""
77
81
  lines
78
82
  end
79
83
 
@@ -386,6 +390,48 @@ module Profiler
386
390
  lines
387
391
  end
388
392
 
393
+ def self.section_related_jobs(profile)
394
+ lines = []
395
+
396
+ # Parent info
397
+ if profile.parent_token
398
+ parent = Profiler.storage.load(profile.parent_token)
399
+ if parent
400
+ lines << "## Triggered By"
401
+ if parent.profile_type == "job"
402
+ job_data = parent.collector_data("job") || {}
403
+ lines << "- **Type:** Job"
404
+ lines << "- **Class:** #{job_data['job_class'] || parent.path}"
405
+ lines << "- **Status:** #{job_data['status']}"
406
+ lines << "- **Duration:** #{parent.duration.round(2)} ms"
407
+ lines << "- **Token:** #{parent.token}"
408
+ else
409
+ lines << "- **Type:** HTTP Request"
410
+ lines << "- **Request:** #{parent.method} #{parent.path}"
411
+ lines << "- **Status:** #{parent.status}"
412
+ lines << "- **Duration:** #{parent.duration.round(2)} ms"
413
+ lines << "- **Token:** #{parent.token}"
414
+ end
415
+ lines << ""
416
+ end
417
+ end
418
+
419
+ # Child jobs
420
+ child_jobs = Profiler.storage.find_by_parent(profile.token).select { |p| p.profile_type == "job" }
421
+ return lines if child_jobs.empty?
422
+
423
+ lines << "## Child Jobs (#{child_jobs.size})"
424
+ lines << ""
425
+ lines << "| Job Class | Status | Duration | Token |"
426
+ lines << "|-----------|--------|----------|-------|"
427
+ child_jobs.each do |job|
428
+ job_data = job.collector_data("job") || {}
429
+ lines << "| #{job_data['job_class'] || job.path} | #{job_data['status'] || '-'} | #{job.duration.round(2)} ms | #{job.token} |"
430
+ end
431
+ lines << ""
432
+ lines
433
+ end
434
+
389
435
  def self.generate_curl(profile, req_data)
390
436
  headers = req_data&.dig("headers") || {}
391
437
  params = req_data&.dig("params") || {}
@@ -4,7 +4,7 @@ module Profiler
4
4
  module MCP
5
5
  module Tools
6
6
  class QueryJobs
7
- ALL_FIELDS = %w[time job_class queue status duration token].freeze
7
+ ALL_FIELDS = %w[time job_class queue status duration token parent_token].freeze
8
8
 
9
9
  def self.call(params)
10
10
  limit = params["limit"]&.to_i || 20
@@ -59,12 +59,13 @@ module Profiler
59
59
  job_data = profile.collector_data("job") || {}
60
60
  row = fields.map do |f|
61
61
  case f
62
- when "time" then profile.started_at.strftime("%H:%M:%S")
63
- when "job_class" then job_data["job_class"] || profile.path
64
- when "queue" then job_data["queue"] || "-"
65
- when "status" then job_data["status"] || "-"
66
- when "duration" then "#{profile.duration.round(2)}ms"
67
- when "token" then profile.token.to_s
62
+ when "time" then profile.started_at.strftime("%H:%M:%S")
63
+ when "job_class" then job_data["job_class"] || profile.path
64
+ when "queue" then job_data["queue"] || "-"
65
+ when "status" then job_data["status"] || "-"
66
+ when "duration" then "#{profile.duration.round(2)}ms"
67
+ when "token" then profile.token.to_s
68
+ when "parent_token" then profile.parent_token || "-"
68
69
  end
69
70
  end
70
71
  lines << "| #{row.join(' | ')} |"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../models/profile"
4
+ require_relative "../current_context"
4
5
  require_relative "toolbar_injector"
5
6
 
6
7
  module Profiler
@@ -14,6 +15,7 @@ module Profiler
14
15
  return @app.call(env) unless should_profile?(env)
15
16
 
16
17
  profile = Models::Profile.new(build_request(env))
18
+ Profiler::CurrentContext.token = profile.token
17
19
 
18
20
  # Capture request body before app processes it
19
21
  req_body_raw = read_rack_input(env)
@@ -64,6 +66,7 @@ module Profiler
64
66
 
65
67
  # Store profile
66
68
  Profiler.storage.save(profile.token, profile)
69
+ Profiler::CurrentContext.clear
67
70
 
68
71
  # Add profiler token header
69
72
  headers["X-Profiler-Token"] = profile.token
@@ -62,6 +62,11 @@ module Profiler
62
62
  chain.add Profiler::Instrumentation::SidekiqMiddleware
63
63
  end
64
64
  end
65
+ Sidekiq.configure_client do |config|
66
+ config.client_middleware do |chain|
67
+ chain.add Profiler::Instrumentation::SidekiqClientMiddleware
68
+ end
69
+ end
65
70
  end
66
71
 
67
72
  if defined?(ActiveJob::Base)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.12.0"
4
+ VERSION = "0.14.0"
5
5
  end
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.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -139,6 +139,7 @@ files:
139
139
  - lib/profiler/collectors/routes_collector.rb
140
140
  - lib/profiler/collectors/view_collector.rb
141
141
  - lib/profiler/configuration.rb
142
+ - lib/profiler/current_context.rb
142
143
  - lib/profiler/engine.rb
143
144
  - lib/profiler/explain_runner.rb
144
145
  - lib/profiler/instrumentation/active_job_instrumentation.rb