rails-profiler 0.13.0 → 0.15.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: e968972f60bc4764a4b61648179a5bf618f2d1f2a68b9d43f724c4e14f3c2794
4
- data.tar.gz: 70536b9f1ed2c5d04b698657ae9f948abad17cbc9fa202125311092b77d94005
3
+ metadata.gz: 4f9f680d793888c215c4b0203f3f4ceb538d415de05916400730fd86561e1c84
4
+ data.tar.gz: d0d944230d9e29d4fea24b54c6ea7134d1994e6624c1e7d0994cd7900a716406
5
5
  SHA512:
6
- metadata.gz: 8e9bb63e8ac85773680d3f83daff09b18a8c440e5272bac926e10d1dd30142969b5442168965711e40eddd9882c05ac592323f0a46410fa6390a3f70064e8521
7
- data.tar.gz: 16bc718c65e6fa50493e9cdc1cc87ae51264f1873cb68baac0db52915a22eb8c383c52523941ab7d2f7d14f86fad2cb6996b74e4e5c175fa7a4e5da3a75272cb
6
+ metadata.gz: b46d2fd2ecc46a051a5616115a0c2ef8b4da155b009ac5734e5e44fe355e14b2b30c4cdeaf52721d403436eebb5076ca6b3c6c3343407d326cdae17394607df7
7
+ data.tar.gz: fc1e2d5d01929200d5fe4f705db509fce7daed7ed13f8a474b31196e397bf797b912a85add2c0f2ae662f3d90b94f65e8dcb383ea142f1706420dfe68b4a961f
@@ -3577,6 +3577,602 @@
3577
3577
  ] });
3578
3578
  }
3579
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
+
3621
+ // app/assets/typescript/profiler/components/dashboard/tabs/EnvTab.tsx
3622
+ function detectType(value) {
3623
+ if (value === "") return { label: "empty", color: "var(--profiler-text-muted)" };
3624
+ if (/^(true|false|yes|no)$/i.test(value)) return { label: "bool", color: "#a78bfa" };
3625
+ if (/^\d+$/.test(value)) return { label: "int", color: "var(--profiler-success,#22c55e)" };
3626
+ if (/^\d+\.\d+$/.test(value)) return { label: "float", color: "var(--profiler-success,#22c55e)" };
3627
+ return { label: "string", color: "var(--profiler-text-muted)" };
3628
+ }
3629
+ function getPrefix(key) {
3630
+ const idx = key.indexOf("_");
3631
+ return idx > 0 ? key.slice(0, idx) : "OTHER";
3632
+ }
3633
+ function parseEnvFile(content) {
3634
+ const result = {};
3635
+ for (const raw of content.split("\n")) {
3636
+ const line = raw.trim();
3637
+ if (!line || line.startsWith("#")) continue;
3638
+ const eq = line.indexOf("=");
3639
+ if (eq < 1) continue;
3640
+ const key = line.slice(0, eq).trim();
3641
+ let value = line.slice(eq + 1);
3642
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3643
+ value = value.slice(1, -1);
3644
+ }
3645
+ result[key] = value;
3646
+ }
3647
+ return result;
3648
+ }
3649
+ function loadUnmasked() {
3650
+ try {
3651
+ const raw = localStorage.getItem("profiler_unmasked_env_keys");
3652
+ return raw ? new Set(JSON.parse(raw)) : /* @__PURE__ */ new Set();
3653
+ } catch {
3654
+ return /* @__PURE__ */ new Set();
3655
+ }
3656
+ }
3657
+ function saveUnmasked(keys) {
3658
+ try {
3659
+ localStorage.setItem("profiler_unmasked_env_keys", JSON.stringify([...keys]));
3660
+ } catch {
3661
+ }
3662
+ }
3663
+ function TypeBadgeComp({ value }) {
3664
+ const badge = detectType(value);
3665
+ if (!badge) return null;
3666
+ return /* @__PURE__ */ u3("span", { style: {
3667
+ display: "inline-block",
3668
+ padding: "0 5px",
3669
+ borderRadius: "3px",
3670
+ fontSize: "10px",
3671
+ fontWeight: 600,
3672
+ fontFamily: "monospace",
3673
+ border: `1px solid ${badge.color}`,
3674
+ color: badge.color,
3675
+ marginRight: "6px",
3676
+ verticalAlign: "middle",
3677
+ lineHeight: "16px",
3678
+ flexShrink: 0
3679
+ }, children: badge.label });
3680
+ }
3681
+ function isBool(value) {
3682
+ return /^(true|false|yes|no)$/i.test(value);
3683
+ }
3684
+ function isQuoted(value) {
3685
+ if (value === "") return false;
3686
+ if (isBool(value)) return false;
3687
+ if (/^\d+(\.\d+)?$/.test(value)) return false;
3688
+ return true;
3689
+ }
3690
+ async function patchEnvVar(key, value) {
3691
+ const res = await fetch("/_profiler/api/env_vars", {
3692
+ method: "PATCH",
3693
+ headers: { "Content-Type": "application/json" },
3694
+ body: JSON.stringify({ key, value })
3695
+ });
3696
+ if (!res.ok) {
3697
+ const body = await res.json().catch(() => ({}));
3698
+ throw new Error(body.error ?? `Request failed (${res.status})`);
3699
+ }
3700
+ }
3701
+ function EnvTab({ envData }) {
3702
+ const initial = T2(() => ({ ...envData?.variables ?? {} }), []);
3703
+ const [variables, setVariables] = d2(initial);
3704
+ const [total, setTotal] = d2(envData?.total ?? 0);
3705
+ const [search, setSearch] = d2("");
3706
+ const [collapsedGroups, setCollapsedGroups] = d2(/* @__PURE__ */ new Set());
3707
+ const [editingKey, setEditingKey] = d2(null);
3708
+ const [editValue, setEditValue] = d2("");
3709
+ const [newKey, setNewKey] = d2("");
3710
+ const [newValue, setNewValue] = d2("");
3711
+ const [flash, setFlash] = d2(null);
3712
+ const [saving, setSaving] = d2(false);
3713
+ const [refreshing, setRefreshing] = d2(false);
3714
+ const [unmaskedKeys, setUnmaskedKeys] = d2(loadUnmasked);
3715
+ const [copiedId, setCopiedId] = d2(null);
3716
+ const [readOnly, setReadOnly] = d2(false);
3717
+ const [showImport, setShowImport] = d2(false);
3718
+ const [importContent, setImportContent] = d2("");
3719
+ const editInputRef = A2(null);
3720
+ y2(() => {
3721
+ if (editingKey !== null) editInputRef.current?.focus();
3722
+ }, [editingKey]);
3723
+ y2(() => {
3724
+ refresh();
3725
+ }, []);
3726
+ const showFlash = (type, message) => {
3727
+ setFlash({ type, message });
3728
+ setTimeout(() => setFlash(null), 3e3);
3729
+ };
3730
+ const toggleUnmask = (key) => {
3731
+ setUnmaskedKeys((prev) => {
3732
+ const next = new Set(prev);
3733
+ if (next.has(key)) next.delete(key);
3734
+ else next.add(key);
3735
+ saveUnmasked(next);
3736
+ return next;
3737
+ });
3738
+ };
3739
+ const copy = async (text, id) => {
3740
+ try {
3741
+ await navigator.clipboard.writeText(text);
3742
+ setCopiedId(id);
3743
+ setTimeout(() => setCopiedId(null), 1500);
3744
+ } catch {
3745
+ showFlash("error", "Clipboard access denied");
3746
+ }
3747
+ };
3748
+ const toggleGroup = (prefix) => {
3749
+ setCollapsedGroups((prev) => {
3750
+ const next = new Set(prev);
3751
+ if (next.has(prefix)) next.delete(prefix);
3752
+ else next.add(prefix);
3753
+ return next;
3754
+ });
3755
+ };
3756
+ const refresh = async () => {
3757
+ setRefreshing(true);
3758
+ try {
3759
+ const res = await fetch("/_profiler/api/env_vars");
3760
+ if (!res.ok) throw new Error(`Request failed (${res.status})`);
3761
+ const data = await res.json();
3762
+ setVariables(data.variables);
3763
+ setTotal(data.total);
3764
+ showFlash("success", `Refreshed \u2014 ${data.total} variables`);
3765
+ } catch (e3) {
3766
+ showFlash("error", e3.message ?? "Failed to refresh");
3767
+ } finally {
3768
+ setRefreshing(false);
3769
+ }
3770
+ };
3771
+ const exportEnv = () => {
3772
+ const entries = filteredEntries;
3773
+ const content = entries.map(([k3, v3]) => `${k3}=${v3}`).join("\n") + "\n";
3774
+ const blob = new Blob([content], { type: "text/plain" });
3775
+ const url = URL.createObjectURL(blob);
3776
+ const a3 = document.createElement("a");
3777
+ a3.href = url;
3778
+ a3.download = ".env";
3779
+ a3.click();
3780
+ URL.revokeObjectURL(url);
3781
+ showFlash("success", `Exported ${entries.length} variables`);
3782
+ };
3783
+ const importPreview = T2(() => parseEnvFile(importContent), [importContent]);
3784
+ const importCount = Object.keys(importPreview).length;
3785
+ const importEnv = async () => {
3786
+ const entries = Object.entries(importPreview);
3787
+ if (!entries.length) return;
3788
+ setSaving(true);
3789
+ try {
3790
+ await Promise.all(entries.map(([k3, v3]) => patchEnvVar(k3, v3)));
3791
+ setVariables((prev) => ({ ...prev, ...importPreview }));
3792
+ setImportContent("");
3793
+ setShowImport(false);
3794
+ showFlash("success", `Imported ${entries.length} variables`);
3795
+ } catch (e3) {
3796
+ showFlash("error", e3.message ?? "Import failed");
3797
+ } finally {
3798
+ setSaving(false);
3799
+ }
3800
+ };
3801
+ const startEdit = (key) => {
3802
+ setEditingKey(key);
3803
+ setEditValue(variables[key] ?? "");
3804
+ };
3805
+ const cancelEdit = () => {
3806
+ setEditingKey(null);
3807
+ setEditValue("");
3808
+ };
3809
+ const saveEdit = async () => {
3810
+ if (!editingKey) return;
3811
+ setSaving(true);
3812
+ try {
3813
+ await patchEnvVar(editingKey, editValue);
3814
+ setVariables((prev) => ({ ...prev, [editingKey]: editValue }));
3815
+ showFlash("success", `${editingKey} updated`);
3816
+ setEditingKey(null);
3817
+ } catch (e3) {
3818
+ showFlash("error", e3.message ?? "Failed to update");
3819
+ } finally {
3820
+ setSaving(false);
3821
+ }
3822
+ };
3823
+ const deleteVar = async (key) => {
3824
+ setSaving(true);
3825
+ try {
3826
+ await patchEnvVar(key, null);
3827
+ setVariables((prev) => {
3828
+ const n2 = { ...prev };
3829
+ delete n2[key];
3830
+ return n2;
3831
+ });
3832
+ setUnmaskedKeys((prev) => {
3833
+ const n2 = new Set(prev);
3834
+ n2.delete(key);
3835
+ saveUnmasked(n2);
3836
+ return n2;
3837
+ });
3838
+ showFlash("success", `${key} deleted`);
3839
+ } catch (e3) {
3840
+ showFlash("error", e3.message ?? "Failed to delete");
3841
+ } finally {
3842
+ setSaving(false);
3843
+ }
3844
+ };
3845
+ const toggleBool = async (key, current) => {
3846
+ const next = /^(true|yes)$/i.test(current) ? "false" : "true";
3847
+ setSaving(true);
3848
+ try {
3849
+ await patchEnvVar(key, next);
3850
+ setVariables((prev) => ({ ...prev, [key]: next }));
3851
+ } catch (e3) {
3852
+ showFlash("error", e3.message ?? "Failed to update");
3853
+ } finally {
3854
+ setSaving(false);
3855
+ }
3856
+ };
3857
+ const addVar = async () => {
3858
+ const key = newKey.trim();
3859
+ if (!key) return;
3860
+ setSaving(true);
3861
+ try {
3862
+ await patchEnvVar(key, newValue);
3863
+ setVariables((prev) => ({ ...prev, [key]: newValue }));
3864
+ setNewKey("");
3865
+ setNewValue("");
3866
+ showFlash("success", `${key} added`);
3867
+ } catch (e3) {
3868
+ showFlash("error", e3.message ?? "Failed to add");
3869
+ } finally {
3870
+ setSaving(false);
3871
+ }
3872
+ };
3873
+ const handleEditKeyDown = (e3) => {
3874
+ if (e3.key === "Enter") saveEdit();
3875
+ if (e3.key === "Escape") cancelEdit();
3876
+ };
3877
+ const allEntries = Object.entries(variables);
3878
+ const filteredEntries = T2(() => {
3879
+ const q2 = search.toLowerCase().trim();
3880
+ if (!q2) return allEntries;
3881
+ return allEntries.filter(
3882
+ ([k3, v3]) => k3.toLowerCase().includes(q2) || v3.toLowerCase().includes(q2)
3883
+ );
3884
+ }, [variables, search]);
3885
+ const groups = T2(() => {
3886
+ const map = {};
3887
+ for (const entry of filteredEntries) {
3888
+ const p3 = getPrefix(entry[0]);
3889
+ (map[p3] ?? (map[p3] = [])).push(entry);
3890
+ }
3891
+ return Object.entries(map).sort(
3892
+ ([a3], [b]) => a3 === "OTHER" ? 1 : b === "OTHER" ? -1 : a3.localeCompare(b)
3893
+ );
3894
+ }, [filteredEntries]);
3895
+ const allRevealed = filteredEntries.every(([k3]) => unmaskedKeys.has(k3));
3896
+ const toggleAllMasks = () => {
3897
+ const keys = filteredEntries.map(([k3]) => k3);
3898
+ setUnmaskedKeys((prev) => {
3899
+ const next = new Set(prev);
3900
+ if (allRevealed) {
3901
+ keys.forEach((k3) => next.delete(k3));
3902
+ } else {
3903
+ keys.forEach((k3) => next.add(k3));
3904
+ }
3905
+ saveUnmasked(next);
3906
+ return next;
3907
+ });
3908
+ };
3909
+ const inputStyle = "width:100%;font-family:monospace;font-size:12px;padding:2px 6px;border:1px solid var(--profiler-border);border-radius:4px;background:var(--profiler-bg);color:var(--profiler-text);";
3910
+ const iconBtn = "background:none;border:none;cursor:pointer;font-size:12px;padding:0 3px;color:var(--profiler-text-muted);opacity:0.7;flex-shrink:0;";
3911
+ const headerBtn = "background:none;border:1px solid var(--profiler-border);border-radius:4px;cursor:pointer;color:var(--profiler-text-muted);font-size:11px;padding:3px 8px;";
3912
+ return /* @__PURE__ */ u3(k, { children: [
3913
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-mb-4", style: "align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;", children: [
3914
+ /* @__PURE__ */ u3("h2", { class: "profiler-section__header", style: "margin:0;", children: "Environment Variables" }),
3915
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
3916
+ /* @__PURE__ */ u3("button", { onClick: refresh, disabled: refreshing, style: headerBtn, children: refreshing ? "\u2026" : "\u21BA Refresh" }),
3917
+ /* @__PURE__ */ u3("button", { onClick: () => setShowImport((v3) => !v3), style: `${headerBtn}${showImport ? "border-color:var(--profiler-accent);color:var(--profiler-accent);" : ""}`, children: "\u2B06 Import" }),
3918
+ /* @__PURE__ */ u3("button", { onClick: exportEnv, style: headerBtn, children: "\u2B07 Export" }),
3919
+ /* @__PURE__ */ u3("button", { onClick: toggleAllMasks, style: headerBtn, children: allRevealed ? "\u{1F648} Mask all" : "\u{1F441} Reveal all" }),
3920
+ /* @__PURE__ */ u3(
3921
+ "button",
3922
+ {
3923
+ onClick: () => setReadOnly((v3) => !v3),
3924
+ title: readOnly ? "Unlock editing" : "Lock editing",
3925
+ style: `${headerBtn}${readOnly ? "border-color:var(--profiler-error,#ef4444);color:var(--profiler-error,#ef4444);" : ""}`,
3926
+ children: readOnly ? "\u{1F512} Locked" : "\u{1F513} Edit"
3927
+ }
3928
+ )
3929
+ ] })
3930
+ ] }),
3931
+ /* @__PURE__ */ u3("div", { style: "background:var(--profiler-warning-bg,rgba(245,158,11,0.1));border:1px solid var(--profiler-warning,#f59e0b);border-radius:6px;padding:8px 12px;margin-bottom:16px;font-size:12px;color:var(--profiler-warning,#f59e0b);", children: "\u26A0 Changes affect the current process only \u2014 for development use." }),
3932
+ flash && /* @__PURE__ */ u3("div", { style: `background:${flash.type === "success" ? "var(--profiler-success-bg,rgba(34,197,94,0.1))" : "var(--profiler-error-bg,rgba(239,68,68,0.08))"};border:1px solid ${flash.type === "success" ? "var(--profiler-success,#22c55e)" : "var(--profiler-error,#ef4444)"};border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:12px;color:${flash.type === "success" ? "var(--profiler-success,#22c55e)" : "var(--profiler-error,#ef4444)"};`, children: [
3933
+ flash.type === "success" ? "\u2713" : "\u2717",
3934
+ " ",
3935
+ flash.message
3936
+ ] }),
3937
+ showImport && /* @__PURE__ */ u3("div", { style: "border:1px solid var(--profiler-border);border-radius:6px;padding:12px;margin-bottom:16px;", children: [
3938
+ /* @__PURE__ */ u3("p", { class: "profiler-text--xs profiler-text--muted", style: "margin:0 0 8px;", children: [
3939
+ "Paste the contents of a ",
3940
+ /* @__PURE__ */ u3("code", { children: ".env" }),
3941
+ " file. Comments and blank lines are ignored."
3942
+ ] }),
3943
+ /* @__PURE__ */ u3(
3944
+ "textarea",
3945
+ {
3946
+ value: importContent,
3947
+ onInput: (e3) => setImportContent(e3.target.value),
3948
+ placeholder: "APP_NAME=MyApp\nFEATURE_BETA=true\n# comment",
3949
+ rows: 6,
3950
+ style: "width:100%;font-family:monospace;font-size:12px;padding:6px 8px;border:1px solid var(--profiler-border);border-radius:4px;background:var(--profiler-bg);color:var(--profiler-text);resize:vertical;box-sizing:border-box;"
3951
+ }
3952
+ ),
3953
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mt-2", style: "align-items:center;", children: [
3954
+ importCount > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: [
3955
+ importCount,
3956
+ " variable",
3957
+ importCount !== 1 ? "s" : "",
3958
+ " to import"
3959
+ ] }),
3960
+ /* @__PURE__ */ u3(
3961
+ "button",
3962
+ {
3963
+ onClick: importEnv,
3964
+ disabled: saving || importCount === 0,
3965
+ style: "background:var(--profiler-accent);border:none;cursor:pointer;color:#fff;font-size:11px;padding:4px 12px;border-radius:4px;margin-left:auto;",
3966
+ children: "Apply"
3967
+ }
3968
+ )
3969
+ ] })
3970
+ ] }),
3971
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-4 profiler-mb-4", children: [
3972
+ /* @__PURE__ */ u3("div", { class: "profiler-stat-card", children: [
3973
+ /* @__PURE__ */ u3("div", { class: "profiler-stat-card__value", children: total }),
3974
+ /* @__PURE__ */ u3("div", { class: "profiler-stat-card__label", children: "Total vars" })
3975
+ ] }),
3976
+ search && /* @__PURE__ */ u3("div", { class: "profiler-stat-card", children: [
3977
+ /* @__PURE__ */ u3("div", { class: "profiler-stat-card__value", children: filteredEntries.length }),
3978
+ /* @__PURE__ */ u3("div", { class: "profiler-stat-card__label", children: "Matching" })
3979
+ ] })
3980
+ ] }),
3981
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mb-3", children: /* @__PURE__ */ u3(
3982
+ "input",
3983
+ {
3984
+ type: "text",
3985
+ class: "profiler-filter-input",
3986
+ placeholder: "Filter by key or value\u2026",
3987
+ value: search,
3988
+ onInput: (e3) => setSearch(e3.target.value),
3989
+ style: "flex:1;"
3990
+ }
3991
+ ) }),
3992
+ /* @__PURE__ */ u3("table", { class: "profiler-table", children: [
3993
+ /* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
3994
+ /* @__PURE__ */ u3("th", { style: "width:32%", children: "Key" }),
3995
+ /* @__PURE__ */ u3("th", { children: "Value" }),
3996
+ !readOnly && /* @__PURE__ */ u3("th", { style: "width:120px;text-align:right;" })
3997
+ ] }) }),
3998
+ /* @__PURE__ */ u3("tbody", { children: [
3999
+ groups.map(([prefix, entries]) => {
4000
+ const collapsed = collapsedGroups.has(prefix);
4001
+ return /* @__PURE__ */ u3(k, { children: [
4002
+ /* @__PURE__ */ u3(
4003
+ "tr",
4004
+ {
4005
+ onClick: () => toggleGroup(prefix),
4006
+ style: "cursor:pointer;background:var(--profiler-bg-subtle,rgba(0,0,0,0.03));",
4007
+ children: /* @__PURE__ */ u3("td", { colspan: readOnly ? 2 : 3, style: "padding:4px 8px;", children: [
4008
+ /* @__PURE__ */ u3("span", { style: "font-size:11px;font-weight:600;color:var(--profiler-text-muted);letter-spacing:0.05em;font-family:monospace;", children: [
4009
+ collapsed ? "\u25B6" : "\u25BC",
4010
+ " ",
4011
+ prefix,
4012
+ "_"
4013
+ ] }),
4014
+ /* @__PURE__ */ u3("span", { style: "font-size:10px;color:var(--profiler-text-muted);margin-left:6px;", children: entries.length })
4015
+ ] })
4016
+ },
4017
+ `group-${prefix}`
4018
+ ),
4019
+ !collapsed && entries.map(([key, value]) => {
4020
+ const unmasked = unmaskedKeys.has(key);
4021
+ const isEditing = editingKey === key;
4022
+ const isCopiedVal = copiedId === key;
4023
+ const isCopiedKey = copiedId === `__key__${key}`;
4024
+ const wasModified = initial[key] !== void 0 && initial[key] !== value;
4025
+ const wasAdded = initial[key] === void 0;
4026
+ const diffStyle = wasModified ? "border-left:3px solid #f59e0b;" : wasAdded ? "border-left:3px solid var(--profiler-success,#22c55e);" : "";
4027
+ return /* @__PURE__ */ u3("tr", { style: diffStyle, children: [
4028
+ /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("div", { class: "profiler-flex", style: "align-items:center;gap:4px;", children: [
4029
+ /* @__PURE__ */ u3("code", { class: "profiler-text--xs profiler-text--mono", children: key }),
4030
+ /* @__PURE__ */ u3(
4031
+ "button",
4032
+ {
4033
+ onClick: () => copy(key, `__key__${key}`),
4034
+ title: "Copy key",
4035
+ style: `${iconBtn}${isCopiedKey ? "color:var(--profiler-success,#22c55e);opacity:1;" : ""}`,
4036
+ children: isCopiedKey ? "\u2713" : "\u2398"
4037
+ }
4038
+ )
4039
+ ] }) }),
4040
+ /* @__PURE__ */ u3("td", { children: isEditing ? /* @__PURE__ */ u3("div", { class: "profiler-flex", style: "align-items:center;gap:2px;", children: [
4041
+ isQuoted(editValue) && /* @__PURE__ */ u3("span", { style: "font-family:monospace;font-size:12px;color:var(--profiler-text-muted);opacity:0.5;flex-shrink:0;", children: '"' }),
4042
+ /* @__PURE__ */ u3(
4043
+ "input",
4044
+ {
4045
+ ref: editInputRef,
4046
+ type: "text",
4047
+ value: editValue,
4048
+ onInput: (e3) => setEditValue(e3.target.value),
4049
+ onKeyDown: handleEditKeyDown,
4050
+ disabled: saving,
4051
+ style: "flex:1;font-family:monospace;font-size:12px;padding:2px 6px;border:1px solid var(--profiler-accent);border-radius:4px;background:var(--profiler-bg);color:var(--profiler-text);"
4052
+ }
4053
+ ),
4054
+ isQuoted(editValue) && /* @__PURE__ */ u3("span", { style: "font-family:monospace;font-size:12px;color:var(--profiler-text-muted);opacity:0.5;flex-shrink:0;", children: '"' })
4055
+ ] }) : isBool(value) ? /* @__PURE__ */ u3(k, { children: [
4056
+ /* @__PURE__ */ u3("div", { class: "profiler-flex", style: "align-items:center;gap:8px;", children: [
4057
+ /* @__PURE__ */ u3(TypeBadgeComp, { value }),
4058
+ /* @__PURE__ */ u3(
4059
+ "button",
4060
+ {
4061
+ onClick: () => !readOnly && !saving && toggleBool(key, value),
4062
+ disabled: saving || readOnly,
4063
+ title: `Click to set ${/^(true|yes)$/i.test(value) ? "false" : "true"}`,
4064
+ style: `position:relative;display:inline-block;width:36px;height:20px;border-radius:10px;border:none;cursor:${readOnly || saving ? "default" : "pointer"};padding:0;transition:background 0.2s;background:${/^(true|yes)$/i.test(value) ? "var(--profiler-accent)" : "var(--profiler-border,#d1d5db)"};flex-shrink:0;`,
4065
+ children: /* @__PURE__ */ u3("span", { style: `position:absolute;top:3px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left 0.2s;left:${/^(true|yes)$/i.test(value) ? "19px" : "3px"};` })
4066
+ }
4067
+ ),
4068
+ /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--mono", style: "color:var(--profiler-text-muted);", children: value }),
4069
+ /* @__PURE__ */ u3(
4070
+ "button",
4071
+ {
4072
+ onClick: () => copy(value, key),
4073
+ title: "Copy value",
4074
+ style: `${iconBtn}${isCopiedVal ? "color:var(--profiler-success,#22c55e);opacity:1;" : ""}`,
4075
+ children: isCopiedVal ? "\u2713" : "\u2398"
4076
+ }
4077
+ )
4078
+ ] }),
4079
+ wasModified && /* @__PURE__ */ u3("div", { style: "font-size:10px;color:#f59e0b;margin-top:2px;font-family:monospace;", children: [
4080
+ "was: ",
4081
+ initial[key]
4082
+ ] })
4083
+ ] }) : /* @__PURE__ */ u3(k, { children: [
4084
+ /* @__PURE__ */ u3("div", { class: "profiler-flex", style: "align-items:center;gap:4px;", children: [
4085
+ /* @__PURE__ */ u3(TypeBadgeComp, { value }),
4086
+ /* @__PURE__ */ u3(
4087
+ "span",
4088
+ {
4089
+ class: "profiler-text--xs profiler-text--mono",
4090
+ style: "word-break:break-all;flex:1;",
4091
+ children: unmasked ? isQuoted(value) ? /* @__PURE__ */ u3(k, { children: [
4092
+ /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted);opacity:0.5;", children: '"' }),
4093
+ value,
4094
+ /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted);opacity:0.5;", children: '"' })
4095
+ ] }) : value : "\u2022".repeat(Math.min(value.length, 20))
4096
+ }
4097
+ ),
4098
+ /* @__PURE__ */ u3(
4099
+ "button",
4100
+ {
4101
+ onClick: () => toggleUnmask(key),
4102
+ title: unmasked ? "Hide value" : "Show value",
4103
+ style: `${iconBtn}${unmasked ? "opacity:1;color:var(--profiler-accent);" : ""}`,
4104
+ children: unmasked ? "\u{1F441}" : "\u{1F648}"
4105
+ }
4106
+ ),
4107
+ /* @__PURE__ */ u3(
4108
+ "button",
4109
+ {
4110
+ onClick: () => copy(value, key),
4111
+ title: "Copy value",
4112
+ style: `${iconBtn}${isCopiedVal ? "color:var(--profiler-success,#22c55e);opacity:1;" : ""}`,
4113
+ children: isCopiedVal ? "\u2713" : "\u2398"
4114
+ }
4115
+ )
4116
+ ] }),
4117
+ wasModified && /* @__PURE__ */ u3("div", { style: "font-size:10px;color:#f59e0b;margin-top:2px;font-family:monospace;", children: [
4118
+ "was: ",
4119
+ unmasked ? isQuoted(initial[key]) ? `"${initial[key]}"` : initial[key] : "\u2022".repeat(Math.min(initial[key].length, 20))
4120
+ ] })
4121
+ ] }) }),
4122
+ !readOnly && /* @__PURE__ */ u3("td", { style: "text-align:right;white-space:nowrap;", children: isEditing ? /* @__PURE__ */ u3(k, { children: [
4123
+ /* @__PURE__ */ u3("button", { onClick: saveEdit, disabled: saving, title: "Save", style: "background:none;border:none;cursor:pointer;color:var(--profiler-success,#22c55e);font-size:14px;padding:0 4px;", children: "\u2713" }),
4124
+ /* @__PURE__ */ u3("button", { onClick: cancelEdit, disabled: saving, title: "Cancel", style: "background:none;border:none;cursor:pointer;color:var(--profiler-text-muted);font-size:14px;padding:0 4px;", children: "\u2717" })
4125
+ ] }) : isBool(value) ? /* @__PURE__ */ u3(k, { children: [
4126
+ /* @__PURE__ */ u3("button", { onClick: () => startEdit(key), disabled: saving, style: "background:none;border:none;cursor:pointer;color:var(--profiler-accent);font-size:11px;padding:0 4px;", children: "Edit" }),
4127
+ /* @__PURE__ */ u3("button", { onClick: () => deleteVar(key), disabled: saving, style: "background:none;border:none;cursor:pointer;color:var(--profiler-error,#ef4444);font-size:11px;padding:0 4px;", children: "Delete" })
4128
+ ] }) : /* @__PURE__ */ u3(k, { children: [
4129
+ /* @__PURE__ */ u3("button", { onClick: () => startEdit(key), disabled: saving, style: "background:none;border:none;cursor:pointer;color:var(--profiler-accent);font-size:11px;padding:0 4px;", children: "Edit" }),
4130
+ /* @__PURE__ */ u3("button", { onClick: () => deleteVar(key), disabled: saving, style: "background:none;border:none;cursor:pointer;color:var(--profiler-error,#ef4444);font-size:11px;padding:0 4px;", children: "Delete" })
4131
+ ] }) })
4132
+ ] }, key);
4133
+ })
4134
+ ] });
4135
+ }),
4136
+ !readOnly && /* @__PURE__ */ u3("tr", { children: [
4137
+ /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3(
4138
+ "input",
4139
+ {
4140
+ type: "text",
4141
+ value: newKey,
4142
+ onInput: (e3) => setNewKey(e3.target.value),
4143
+ onKeyDown: (e3) => e3.key === "Enter" && addVar(),
4144
+ placeholder: "NEW_KEY",
4145
+ disabled: saving,
4146
+ style: inputStyle
4147
+ }
4148
+ ) }),
4149
+ /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3(
4150
+ "input",
4151
+ {
4152
+ type: "text",
4153
+ value: newValue,
4154
+ onInput: (e3) => setNewValue(e3.target.value),
4155
+ onKeyDown: (e3) => e3.key === "Enter" && addVar(),
4156
+ placeholder: "value",
4157
+ disabled: saving,
4158
+ style: inputStyle
4159
+ }
4160
+ ) }),
4161
+ /* @__PURE__ */ u3("td", { style: "text-align:right;", children: /* @__PURE__ */ u3(
4162
+ "button",
4163
+ {
4164
+ onClick: addVar,
4165
+ disabled: saving || !newKey.trim(),
4166
+ style: "background:var(--profiler-accent);border:none;cursor:pointer;color:#fff;font-size:11px;padding:3px 8px;border-radius:4px;",
4167
+ children: "Add"
4168
+ }
4169
+ ) })
4170
+ ] })
4171
+ ] })
4172
+ ] })
4173
+ ] });
4174
+ }
4175
+
3580
4176
  // app/assets/typescript/profiler/components/dashboard/ProfileDashboard.tsx
3581
4177
  function ProfileDashboard({ profile, initialTab, embedded }) {
3582
4178
  const cd = profile.collectors_data || {};
@@ -3586,6 +4182,7 @@
3586
4182
  const hasLogs = (cd["logs"]?.count ?? 0) > 0;
3587
4183
  const hasRoutes = (cd["routes"]?.total ?? 0) > 0;
3588
4184
  const hasI18n = (cd["i18n"]?.total ?? 0) > 0;
4185
+ const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
3589
4186
  const [activeTab, setActiveTab] = d2(hasException ? "exception" : initialTab);
3590
4187
  const handleTabClick = (tab) => (e3) => {
3591
4188
  e3.preventDefault();
@@ -3641,7 +4238,13 @@
3641
4238
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("cache"), onClick: handleTabClick("cache"), children: "Cache" }),
3642
4239
  hasLogs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("logs"), onClick: handleTabClick("logs"), children: "Logs" }),
3643
4240
  hasRoutes && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("routes"), onClick: handleTabClick("routes"), children: "Routes" }),
3644
- hasI18n && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("i18n"), onClick: handleTabClick("i18n"), children: "I18n" })
4241
+ hasI18n && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("i18n"), onClick: handleTabClick("i18n"), children: "I18n" }),
4242
+ hasJobs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("jobs"), onClick: handleTabClick("jobs"), children: [
4243
+ "Jobs (",
4244
+ profile.child_jobs.length,
4245
+ ")"
4246
+ ] }),
4247
+ /* @__PURE__ */ u3("a", { href: "#", class: tabClass("env"), onClick: handleTabClick("env"), children: "Env" })
3645
4248
  ] }),
3646
4249
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
3647
4250
  activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
@@ -3655,7 +4258,9 @@
3655
4258
  activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
3656
4259
  activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
3657
4260
  activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] }),
3658
- activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] })
4261
+ activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] }),
4262
+ activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs }),
4263
+ activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"] })
3659
4264
  ] })
3660
4265
  ] }),
3661
4266
  !embedded && /* @__PURE__ */ u3("div", { class: "profiler-mt-6", children: /* @__PURE__ */ u3("a", { href: "/_profiler", style: "color: var(--profiler-accent);", children: "\u2190 Back to profiles" }) })
@@ -3718,13 +4323,15 @@
3718
4323
 
3719
4324
  // app/assets/typescript/profiler/components/dashboard/JobProfileDashboard.tsx
3720
4325
  function JobProfileDashboard({ profile, initialTab, embedded }) {
3721
- const validTabs = ["job", "database", "cache", "http"];
4326
+ const validTabs = ["job", "database", "cache", "http", "jobs"];
3722
4327
  const defaultTab = validTabs.includes(initialTab) ? initialTab : "job";
3723
4328
  const [activeTab, setActiveTab] = d2(defaultTab);
3724
4329
  const cd = profile.collectors_data || {};
3725
4330
  const hasHttp = cd["http"]?.total_requests > 0;
4331
+ const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
3726
4332
  const jobData = cd["job"];
3727
4333
  const isFailed = jobData?.status === "failed";
4334
+ const parent = profile.parent_profile;
3728
4335
  const handleTabClick = (tab) => (e3) => {
3729
4336
  e3.preventDefault();
3730
4337
  setActiveTab(tab);
@@ -3759,6 +4366,26 @@
3759
4366
  " MB"
3760
4367
  ] })
3761
4368
  ] })
4369
+ ] }),
4370
+ parent && /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mt-2 profiler-text--sm", children: [
4371
+ /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted)", children: "Triggered by:" }),
4372
+ parent.profile_type === "http" ? /* @__PURE__ */ u3("span", { children: [
4373
+ /* @__PURE__ */ u3("strong", { children: parent.method }),
4374
+ " ",
4375
+ parent.path,
4376
+ " \xB7 ",
4377
+ parent.http_status,
4378
+ " \xB7 ",
4379
+ parent.duration?.toFixed(2),
4380
+ " ms"
4381
+ ] }) : /* @__PURE__ */ u3("span", { children: [
4382
+ "\u2699\uFE0F ",
4383
+ /* @__PURE__ */ u3("strong", { children: parent.path }),
4384
+ " \xB7 ",
4385
+ parent.duration?.toFixed(2),
4386
+ " ms"
4387
+ ] }),
4388
+ /* @__PURE__ */ u3("a", { href: `/_profiler/profiles/${parent.token}`, style: "color: var(--profiler-accent);", children: "View \u2192" })
3762
4389
  ] })
3763
4390
  ] }),
3764
4391
  /* @__PURE__ */ u3("div", { class: "profiler-panel profiler-mb-6", children: [
@@ -3766,13 +4393,19 @@
3766
4393
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("job"), onClick: handleTabClick("job"), children: "Job" }),
3767
4394
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("database"), onClick: handleTabClick("database"), children: "Database" }),
3768
4395
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("cache"), onClick: handleTabClick("cache"), children: "Cache" }),
3769
- hasHttp && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("http"), onClick: handleTabClick("http"), children: "Outbound HTTP" })
4396
+ hasHttp && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("http"), onClick: handleTabClick("http"), children: "Outbound HTTP" }),
4397
+ hasJobs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("jobs"), onClick: handleTabClick("jobs"), children: [
4398
+ "Jobs (",
4399
+ profile.child_jobs.length,
4400
+ ")"
4401
+ ] })
3770
4402
  ] }),
3771
4403
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
3772
4404
  activeTab === "job" && /* @__PURE__ */ u3(JobTab, { jobData: cd["job"] }),
3773
4405
  activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
3774
4406
  activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
3775
- activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] })
4407
+ activeTab === "http" && /* @__PURE__ */ u3(HttpTab, { httpData: cd["http"] }),
4408
+ activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs })
3776
4409
  ] })
3777
4410
  ] }),
3778
4411
  !embedded && /* @__PURE__ */ u3("div", { class: "profiler-mt-6", children: /* @__PURE__ */ u3("a", { href: "/_profiler", style: "color: var(--profiler-accent);", children: "\u2190 Back to profiles" }) })
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class EnvVarsController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def show
9
+ variables = ENV.to_h.sort.to_h
10
+ render json: { variables: variables, total: variables.size }
11
+ end
12
+
13
+ def update
14
+ key = params[:key].to_s.strip
15
+
16
+ if key.blank?
17
+ render json: { error: "Key cannot be blank" }, status: :unprocessable_entity
18
+ return
19
+ end
20
+
21
+ value = params[:value]
22
+
23
+ if value.nil? || value.to_s.empty?
24
+ ENV.delete(key)
25
+ render json: { key: key, value: nil, deleted: true }
26
+ else
27
+ ENV[key] = value.to_s
28
+ render json: { key: key, value: ENV[key] }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -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>
data/config/routes.rb CHANGED
@@ -34,5 +34,6 @@ Profiler::Engine.routes.draw do
34
34
  post "ajax/link", to: "ajax#link"
35
35
  post "explain", to: "explain#create"
36
36
  resource :function_profiling, only: [:show, :update], controller: "function_profiling"
37
+ resource :env_vars, only: [:show, :update], controller: "env_vars"
37
38
  end
38
39
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class EnvCollector < BaseCollector
8
+ def icon
9
+ "⚙️"
10
+ end
11
+
12
+ def priority
13
+ 90
14
+ end
15
+
16
+ def tab_config
17
+ {
18
+ key: "env",
19
+ label: "Env",
20
+ icon: icon,
21
+ priority: priority,
22
+ enabled: true,
23
+ default_active: false
24
+ }
25
+ end
26
+
27
+ def collect
28
+ variables = ENV.to_h.sort.to_h
29
+ store_data({ variables: variables, total: variables.size })
30
+ end
31
+
32
+ def toolbar_summary
33
+ data = panel_content
34
+ { text: "#{data[:total] || 0} vars", color: "gray" }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -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.13.0"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -94,6 +94,7 @@ require_relative "profiler/collectors/log_collector"
94
94
  require_relative "profiler/collectors/exception_collector"
95
95
  require_relative "profiler/collectors/routes_collector"
96
96
  require_relative "profiler/collectors/i18n_collector"
97
+ require_relative "profiler/collectors/env_collector"
97
98
 
98
99
  require_relative "profiler/railtie" if defined?(Rails::Railtie)
99
100
  require_relative "profiler/engine" if defined?(Rails::Engine)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-09 00:00:00.000000000 Z
11
+ date: 2026-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -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/env_vars_controller.rb
110
111
  - app/controllers/profiler/api/explain_controller.rb
111
112
  - app/controllers/profiler/api/function_profiling_controller.rb
112
113
  - app/controllers/profiler/api/jobs_controller.rb
@@ -128,6 +129,7 @@ files:
128
129
  - lib/profiler/collectors/cache_collector.rb
129
130
  - lib/profiler/collectors/database_collector.rb
130
131
  - lib/profiler/collectors/dump_collector.rb
132
+ - lib/profiler/collectors/env_collector.rb
131
133
  - lib/profiler/collectors/exception_collector.rb
132
134
  - lib/profiler/collectors/flamegraph_collector.rb
133
135
  - lib/profiler/collectors/function_profiler_collector.rb
@@ -139,6 +141,7 @@ files:
139
141
  - lib/profiler/collectors/routes_collector.rb
140
142
  - lib/profiler/collectors/view_collector.rb
141
143
  - lib/profiler/configuration.rb
144
+ - lib/profiler/current_context.rb
142
145
  - lib/profiler/engine.rb
143
146
  - lib/profiler/explain_runner.rb
144
147
  - lib/profiler/instrumentation/active_job_instrumentation.rb