rails-profiler 0.28.0 → 0.30.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler-toolbar.js +60 -1
  3. data/app/assets/builds/profiler.js +120 -31
  4. data/app/controllers/profiler/api/cluster_controller.rb +35 -0
  5. data/app/controllers/profiler/api/events_controller.rb +35 -0
  6. data/app/controllers/profiler/api/profiles_controller.rb +8 -2
  7. data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
  8. data/app/views/layouts/profiler/application.html.erb +3 -0
  9. data/config/routes.rb +11 -0
  10. data/lib/profiler/cluster/master_client.rb +79 -0
  11. data/lib/profiler/cluster/slave_proxy.rb +106 -0
  12. data/lib/profiler/cluster/slave_registry.rb +50 -0
  13. data/lib/profiler/configuration.rb +20 -1
  14. data/lib/profiler/mcp/server.rb +49 -20
  15. data/lib/profiler/mcp/slave_support.rb +23 -0
  16. data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
  17. data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
  18. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  19. data/lib/profiler/mcp/tools/explain_query.rb +8 -0
  20. data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
  21. data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
  22. data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
  23. data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
  24. data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
  25. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
  26. data/lib/profiler/mcp/tools/list_env_vars.rb +38 -0
  27. data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
  28. data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
  29. data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
  30. data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
  31. data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
  32. data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
  33. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
  35. data/lib/profiler/mcp/tools/run_tests.rb +41 -0
  36. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  37. data/lib/profiler/railtie.rb +11 -0
  38. data/lib/profiler/sse/bus.rb +13 -0
  39. data/lib/profiler/sse/event_bus.rb +47 -0
  40. data/lib/profiler/sse/redis_event_bus.rb +79 -0
  41. data/lib/profiler/storage/base_store.rb +18 -1
  42. data/lib/profiler/storage/file_store.rb +1 -1
  43. data/lib/profiler/storage/memory_store.rb +1 -1
  44. data/lib/profiler/storage/redis_store.rb +3 -1
  45. data/lib/profiler/storage/sqlite_store.rb +1 -1
  46. data/lib/profiler/version.rb +1 -1
  47. data/lib/profiler.rb +10 -0
  48. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43436010e8f0c4e97b8152996bac90d06192e9975ddb3158bbe52281c8d0e32a
4
- data.tar.gz: ce88d5f1649e87f1e540a44f6752c5c1d8559ef3ef10ea4727aa1e467fd1ca3c
3
+ metadata.gz: fcc4a5ad4cdedde3dd6427df52aeb75a54c4c8e973e0816f5f3d71e958042a89
4
+ data.tar.gz: b4f2c42db2a3a4fd0d1afd3648dcde087284dd3cf35aef0bca86049a832f3f71
5
5
  SHA512:
6
- metadata.gz: f24eb1eea1c94389c4ff9557707f07f8b1f49359178b8c9595684b00593a50b398e7b132c5c5a09c4096c884a6183f7621cf5e48efb4944deac55c9ed71197c8
7
- data.tar.gz: 7f216e42ddec0d00560b60e3c91a71353826407e11027a41c224e55e6b4633dc143b96b6853fa7a576441ad707d979b02a7fb9d595f74e6206e062cf6fead0c1
6
+ metadata.gz: 3e0083dfee931dcc4e1d13eff5e873cd18eb24a8853ce503b57a38e4ec7bd3d32862e4298cf8a4cc100ee4b47863eb715a0317488e41b0b9e228127d7fcd62fd
7
+ data.tar.gz: 8eabcaefdfeaf5fd5fbc0149aacb179dd139b05c92157fa48708484e140c868c36869c3d03023ffcfc7603f318fde2e156ef292ca1265406d176de591c832d64
@@ -4146,9 +4146,26 @@
4146
4146
  ] }) });
4147
4147
  }
4148
4148
 
4149
+ // app/assets/typescript/profiler/cluster-context.ts
4150
+ var STORAGE_KEY = "profiler-active-slave";
4151
+ var activeSlavePrefix = (() => {
4152
+ try {
4153
+ const stored = sessionStorage.getItem(STORAGE_KEY);
4154
+ return stored ? `/slaves/${stored}` : "";
4155
+ } catch {
4156
+ return "";
4157
+ }
4158
+ })();
4159
+ function getActiveSlavePrefix() {
4160
+ return activeSlavePrefix;
4161
+ }
4162
+
4149
4163
  // app/assets/typescript/profiler/api-fetcher.ts
4150
4164
  async function apiFetch(config) {
4151
- const { url, method, params, data, headers = {}, signal } = config;
4165
+ const prefix = getActiveSlavePrefix();
4166
+ const resolvedUrl = prefix ? config.url.replace("/_profiler/api", `/_profiler/api${prefix}`) : config.url;
4167
+ const { method, params, data, headers = {}, signal } = config;
4168
+ const url = resolvedUrl;
4152
4169
  const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
4153
4170
  const res = await fetch(url + qs, {
4154
4171
  method,
@@ -4192,6 +4209,45 @@
4192
4209
  return query;
4193
4210
  }
4194
4211
 
4212
+ // app/assets/typescript/profiler/hooks/useProfileEvents.ts
4213
+ var BASE = "/_profiler";
4214
+ function useProfileEvents(token, collectors, onUpdate) {
4215
+ const [connected, setConnected] = d2(false);
4216
+ const fallbackRef = A2(null);
4217
+ y2(() => {
4218
+ if (!token) return;
4219
+ const clearFallback = () => {
4220
+ if (fallbackRef.current !== null) {
4221
+ clearInterval(fallbackRef.current);
4222
+ fallbackRef.current = null;
4223
+ }
4224
+ };
4225
+ const params = new URLSearchParams();
4226
+ collectors.forEach((c3) => params.append("collectors[]", c3));
4227
+ const url = `${BASE}/api/events/${token}?${params.toString()}`;
4228
+ const es = new EventSource(url);
4229
+ es.addEventListener("profile_update", () => {
4230
+ onUpdate();
4231
+ });
4232
+ es.onopen = () => {
4233
+ setConnected(true);
4234
+ clearFallback();
4235
+ };
4236
+ es.onerror = () => {
4237
+ es.close();
4238
+ setConnected(false);
4239
+ if (fallbackRef.current === null) {
4240
+ fallbackRef.current = setInterval(() => onUpdate(), 1e4);
4241
+ }
4242
+ };
4243
+ return () => {
4244
+ es.close();
4245
+ clearFallback();
4246
+ };
4247
+ }, [token]);
4248
+ return { connected };
4249
+ }
4250
+
4195
4251
  // app/assets/typescript/profiler/toolbar-bundle.tsx
4196
4252
  var ANIM_MS = 280;
4197
4253
  function applyTheme(elements) {
@@ -4209,6 +4265,9 @@
4209
4265
  refetch();
4210
4266
  };
4211
4267
  }, [refetch]);
4268
+ useProfileEvents(token, [], () => {
4269
+ refetch();
4270
+ });
4212
4271
  const profile = data?.profile ?? null;
4213
4272
  if (!profile) return null;
4214
4273
  return /* @__PURE__ */ u3(ToolbarApp, { profile, token });
@@ -3419,9 +3419,42 @@
3419
3419
  );
3420
3420
  }
3421
3421
 
3422
+ // app/assets/typescript/profiler/cluster-context.ts
3423
+ var STORAGE_KEY = "profiler-active-slave";
3424
+ var activeSlavePrefix = (() => {
3425
+ try {
3426
+ const stored = sessionStorage.getItem(STORAGE_KEY);
3427
+ return stored ? `/slaves/${stored}` : "";
3428
+ } catch {
3429
+ return "";
3430
+ }
3431
+ })();
3432
+ function getActiveSlavePrefix() {
3433
+ return activeSlavePrefix;
3434
+ }
3435
+ function getActiveSlaveName() {
3436
+ if (!activeSlavePrefix) return null;
3437
+ return activeSlavePrefix.replace("/slaves/", "");
3438
+ }
3439
+ function setActiveSlave(name) {
3440
+ try {
3441
+ if (name) {
3442
+ sessionStorage.setItem(STORAGE_KEY, name);
3443
+ } else {
3444
+ sessionStorage.removeItem(STORAGE_KEY);
3445
+ }
3446
+ } catch {
3447
+ }
3448
+ activeSlavePrefix = name ? `/slaves/${name}` : "";
3449
+ window.location.reload();
3450
+ }
3451
+
3422
3452
  // app/assets/typescript/profiler/api-fetcher.ts
3423
3453
  async function apiFetch(config) {
3424
- const { url, method, params, data, headers = {}, signal } = config;
3454
+ const prefix = getActiveSlavePrefix();
3455
+ const resolvedUrl = prefix ? config.url.replace("/_profiler/api", `/_profiler/api${prefix}`) : config.url;
3456
+ const { method, params, data, headers = {}, signal } = config;
3457
+ const url = resolvedUrl;
3425
3458
  const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
3426
3459
  const res = await fetch(url + qs, {
3427
3460
  method,
@@ -4390,8 +4423,8 @@
4390
4423
  open && /* @__PURE__ */ u3(
4391
4424
  HttpReqRespDetail,
4392
4425
  {
4393
- request: { headers: req.request_headers || {}, body: req.request_body, body_encoding: req.request_body_encoding },
4394
- response: { headers: req.response_headers || {}, body: req.response_body, body_encoding: req.response_body_encoding },
4426
+ request: { headers: req.request_headers || {}, body: req.request_body ?? void 0, body_encoding: req.request_body_encoding },
4427
+ response: { headers: req.response_headers || {}, body: req.response_body ?? void 0, body_encoding: req.response_body_encoding },
4395
4428
  backtrace: req.backtrace
4396
4429
  }
4397
4430
  )
@@ -4626,7 +4659,7 @@
4626
4659
  if (!entries.length) return;
4627
4660
  setSaving(true);
4628
4661
  try {
4629
- await Promise.all(entries.map(([k3, v3]) => patchEnvVar({ key: k3, value: v3 })));
4662
+ await Promise.all(entries.map(([k3, v3]) => patchEnvVar({ data: { key: k3, value: v3 } })));
4630
4663
  setVariables((prev) => ({ ...prev, ...importPreview }));
4631
4664
  setImportContent("");
4632
4665
  setShowImport(false);
@@ -4654,7 +4687,7 @@
4654
4687
  }
4655
4688
  setSaving(true);
4656
4689
  try {
4657
- const data = await patchEnvVar({ key, value: editValue });
4690
+ const data = await patchEnvVar({ data: { key, value: editValue } });
4658
4691
  setVariables((prev) => ({ ...prev, [key]: editValue }));
4659
4692
  setOverrides((prev) => {
4660
4693
  const n2 = { ...prev };
@@ -4676,7 +4709,7 @@
4676
4709
  const deleteVar = async (key) => {
4677
4710
  setSaving(true);
4678
4711
  try {
4679
- const data = await patchEnvVar({ key, value: null });
4712
+ const data = await patchEnvVar({ data: { key, value: null } });
4680
4713
  setVariables((prev) => {
4681
4714
  const n2 = { ...prev };
4682
4715
  delete n2[key];
@@ -4708,7 +4741,7 @@
4708
4741
  const next = /^(true|yes)$/i.test(current) ? "false" : "true";
4709
4742
  setSaving(true);
4710
4743
  try {
4711
- const data = await patchEnvVar({ key, value: next });
4744
+ const data = await patchEnvVar({ data: { key, value: next } });
4712
4745
  setVariables((prev) => ({ ...prev, [key]: next }));
4713
4746
  setOverrides((prev) => {
4714
4747
  const n2 = { ...prev };
@@ -4730,7 +4763,7 @@
4730
4763
  if (!key) return;
4731
4764
  setSaving(true);
4732
4765
  try {
4733
- const data = await patchEnvVar({ key, value: newValue });
4766
+ const data = await patchEnvVar({ data: { key, value: newValue } });
4734
4767
  setVariables((prev) => ({ ...prev, [key]: newValue }));
4735
4768
  setOverrides((prev) => {
4736
4769
  const n2 = { ...prev };
@@ -4757,7 +4790,7 @@
4757
4790
  const resetVar = async (key) => {
4758
4791
  setSaving(true);
4759
4792
  try {
4760
- const data = await resetEnvVar2({ key });
4793
+ const data = await resetEnvVar2({ params: { key } });
4761
4794
  setOverrides((prev) => {
4762
4795
  const n2 = { ...prev };
4763
4796
  delete n2[key];
@@ -4765,7 +4798,7 @@
4765
4798
  });
4766
4799
  const updater = (prev) => {
4767
4800
  const n2 = { ...prev };
4768
- if (data.value === null) {
4801
+ if (data.value === null || data.value === void 0) {
4769
4802
  delete n2[key];
4770
4803
  } else {
4771
4804
  n2[key] = data.value;
@@ -4784,7 +4817,7 @@
4784
4817
  const doResetAll = async () => {
4785
4818
  setSaving(true);
4786
4819
  try {
4787
- await resetAllEnvVars2({});
4820
+ await resetAllEnvVars2();
4788
4821
  setOverrides({});
4789
4822
  const data = await refresh();
4790
4823
  if (data) setInitial(data.variables);
@@ -5397,7 +5430,7 @@
5397
5430
  if (selected.size === 0 || isRunning) return;
5398
5431
  setError(null);
5399
5432
  try {
5400
- const data = await startTestRun({ files: Array.from(selected), framework });
5433
+ const data = await startTestRun({ data: { files: Array.from(selected), framework } });
5401
5434
  setCurrentRun(data);
5402
5435
  setIsRunning(true);
5403
5436
  } catch {
@@ -6004,7 +6037,7 @@
6004
6037
  " ms"
6005
6038
  ] }) }),
6006
6039
  /* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "\u2014" }),
6007
- /* @__PURE__ */ u3("td", { children: formatMemory(p3.memory) }),
6040
+ /* @__PURE__ */ u3("td", { children: formatMemory(p3.memory ?? void 0) }),
6008
6041
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
6009
6042
  /* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
6010
6043
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("button", { class: "btn-row-delete", onClick: () => deleteProfile2(p3.token), title: "Delete", children: "\xD7" }) })
@@ -6321,6 +6354,53 @@
6321
6354
  ] });
6322
6355
  }
6323
6356
 
6357
+ // app/assets/typescript/profiler/components/ProfilerSelector.tsx
6358
+ function ProfilerSelector() {
6359
+ const [slaves, setSlaves] = d2([]);
6360
+ const [loading, setLoading] = d2(true);
6361
+ const activeSlave = getActiveSlaveName();
6362
+ y2(() => {
6363
+ fetch("/_profiler/api/cluster/slaves").then((r3) => r3.json()).then((data) => {
6364
+ setSlaves(data.slaves ?? []);
6365
+ setLoading(false);
6366
+ }).catch(() => setLoading(false));
6367
+ }, []);
6368
+ if (loading || slaves.length === 0) return null;
6369
+ const handleChange = (e3) => {
6370
+ const value = e3.target.value;
6371
+ setActiveSlave(value === "__local__" ? null : value);
6372
+ };
6373
+ const currentValue = activeSlave ?? "__local__";
6374
+ return /* @__PURE__ */ u3("div", { class: "profiler-selector", style: { display: "flex", alignItems: "center", gap: "8px", padding: "4px 12px", background: "var(--profiler-bg-secondary, #f5f5f5)", borderBottom: "1px solid var(--profiler-border, #e0e0e0)" }, children: [
6375
+ /* @__PURE__ */ u3("span", { style: { fontSize: "12px", fontWeight: 600, color: "var(--profiler-text-secondary, #666)", textTransform: "uppercase", letterSpacing: "0.05em" }, children: "Profiler" }),
6376
+ /* @__PURE__ */ u3(
6377
+ "select",
6378
+ {
6379
+ value: currentValue,
6380
+ onChange: handleChange,
6381
+ style: { fontSize: "13px", padding: "2px 6px", borderRadius: "4px", border: "1px solid var(--profiler-border, #ccc)", background: "var(--profiler-bg, #fff)", color: "var(--profiler-text, #333)", cursor: "pointer" },
6382
+ children: [
6383
+ /* @__PURE__ */ u3("option", { value: "__local__", children: "Local (master)" }),
6384
+ slaves.map((slave) => /* @__PURE__ */ u3(
6385
+ "option",
6386
+ {
6387
+ value: slave.name,
6388
+ disabled: slave.status === "offline",
6389
+ children: [
6390
+ slave.name,
6391
+ " ",
6392
+ slave.status === "offline" ? "(offline)" : ""
6393
+ ]
6394
+ },
6395
+ slave.name
6396
+ ))
6397
+ ]
6398
+ }
6399
+ ),
6400
+ activeSlave && /* @__PURE__ */ u3("span", { style: { fontSize: "11px", color: "var(--profiler-text-secondary, #888)", fontStyle: "italic" }, children: "Viewing slave data via proxy" })
6401
+ ] });
6402
+ }
6403
+
6324
6404
  // app/assets/typescript/profiler/components/dashboard/tabs/RequestTab.tsx
6325
6405
  function buildCurl2(profile) {
6326
6406
  const headers = profile.headers ?? {};
@@ -6376,13 +6456,13 @@
6376
6456
  {
6377
6457
  request: {
6378
6458
  headers: profile.headers ?? {},
6379
- body: profile.request_body,
6459
+ body: profile.request_body ?? void 0,
6380
6460
  body_encoding: profile.request_body_encoding,
6381
6461
  params: hasParams ? profile.params : void 0
6382
6462
  },
6383
6463
  response: {
6384
6464
  headers: profile.response_headers ?? {},
6385
- body: profile.response_body,
6465
+ body: profile.response_body ?? void 0,
6386
6466
  body_encoding: profile.response_body_encoding
6387
6467
  }
6388
6468
  }
@@ -6525,7 +6605,7 @@
6525
6605
  const runExplain = async (queryIndex) => {
6526
6606
  setExplainState({ open: true, loading: true, result: null, format: "text", adapter: "", error: null });
6527
6607
  try {
6528
- const data = await runExplainMutation({ token, query_index: queryIndex });
6608
+ const data = await runExplainMutation({ data: { token, query_index: queryIndex } });
6529
6609
  setExplainState((s3) => ({
6530
6610
  ...s3,
6531
6611
  loading: false,
@@ -7155,7 +7235,8 @@
7155
7235
  if (node.payload && category !== "method") {
7156
7236
  let payloadText = null;
7157
7237
  if (category === "sql" && node.payload.sql) {
7158
- payloadText = node.payload.sql.length > 200 ? node.payload.sql.slice(0, 200) + "..." : node.payload.sql;
7238
+ const sql = node.payload.sql;
7239
+ payloadText = sql.length > 200 ? sql.slice(0, 200) + "..." : sql;
7159
7240
  } else if (category === "cache" && node.payload.key) {
7160
7241
  payloadText = `Key: ${node.payload.key}`;
7161
7242
  } else if (category === "http" && node.payload.url) {
@@ -7283,7 +7364,7 @@
7283
7364
  const toggleFunctionProfiling = async () => {
7284
7365
  setFnToggling(true);
7285
7366
  try {
7286
- const json = await patchFunctionProfiling({ enabled: !fnEnabled });
7367
+ const json = await patchFunctionProfiling({ data: { enabled: !fnEnabled } });
7287
7368
  setFnEnabled(json.enabled);
7288
7369
  } finally {
7289
7370
  setFnToggling(false);
@@ -7292,7 +7373,7 @@
7292
7373
  const updateMaxFrames = async (value) => {
7293
7374
  setFnMaxFramesUpdating(true);
7294
7375
  try {
7295
- const json = await patchFunctionProfiling({ max_frames: value });
7376
+ const json = await patchFunctionProfiling({ data: { max_frames: value } });
7296
7377
  setFnMaxFrames(json.max_frames);
7297
7378
  } finally {
7298
7379
  setFnMaxFramesUpdating(false);
@@ -7302,7 +7383,7 @@
7302
7383
  setFnMode(value);
7303
7384
  setFnModeUpdating(true);
7304
7385
  try {
7305
- const json = await patchFunctionProfiling({ mode: value });
7386
+ const json = await patchFunctionProfiling({ data: { mode: value } });
7306
7387
  setFnMode(json.mode ?? value);
7307
7388
  } finally {
7308
7389
  setFnModeUpdating(false);
@@ -7311,7 +7392,7 @@
7311
7392
  const updateClock = async (value) => {
7312
7393
  setFnClockUpdating(true);
7313
7394
  try {
7314
- const json = await patchFunctionProfiling({ clock: value });
7395
+ const json = await patchFunctionProfiling({ data: { clock: value } });
7315
7396
  setFnClock(json.clock ?? value);
7316
7397
  } finally {
7317
7398
  setFnClockUpdating(false);
@@ -8518,7 +8599,7 @@
8518
8599
  mode === "preview" && hasHtml && /* @__PURE__ */ u3(
8519
8600
  "iframe",
8520
8601
  {
8521
- srcdoc: email.body_html,
8602
+ srcdoc: email.body_html ?? void 0,
8522
8603
  sandbox: "allow-same-origin",
8523
8604
  style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
8524
8605
  }
@@ -9228,11 +9309,11 @@
9228
9309
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
9229
9310
  activeTab === "test" && testData && /* @__PURE__ */ u3(TestTab, { testData }),
9230
9311
  activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
9231
- activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { data: cd["cache"] }),
9232
- activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { data: cd["flamegraph"] }),
9233
- activeTab === "dump" && hasDumps && /* @__PURE__ */ u3(DumpsTab, { data: cd["dump"] }),
9234
- activeTab === "logs" && hasLogs && /* @__PURE__ */ u3(LogsTab, { data: cd["logs"] }),
9235
- activeTab === "exception" && hasException && /* @__PURE__ */ u3(ExceptionTab, { data: cd["exception"] }),
9312
+ activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
9313
+ activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"], functionProfileData: cd["function_profile"] }),
9314
+ activeTab === "dump" && hasDumps && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
9315
+ activeTab === "logs" && hasLogs && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
9316
+ activeTab === "exception" && hasException && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
9236
9317
  activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"] })
9237
9318
  ] })
9238
9319
  ] })
@@ -9382,7 +9463,7 @@ Duration: ${event.duration.toFixed(2)}ms`;
9382
9463
  }
9383
9464
 
9384
9465
  // app/assets/typescript/profiler/theme.ts
9385
- var STORAGE_KEY = "profiler-theme";
9466
+ var STORAGE_KEY2 = "profiler-theme";
9386
9467
  var THEME_ATTRIBUTE = "data-theme";
9387
9468
  var ThemeManager = class {
9388
9469
  constructor() {
@@ -9398,18 +9479,18 @@ Duration: ${event.duration.toFixed(2)}ms`;
9398
9479
  }
9399
9480
  });
9400
9481
  window.addEventListener("storage", (e3) => {
9401
- if (e3.key === STORAGE_KEY && e3.newValue) {
9482
+ if (e3.key === STORAGE_KEY2 && e3.newValue) {
9402
9483
  this.currentTheme = e3.newValue;
9403
9484
  this.applyTheme(this.currentTheme);
9404
9485
  }
9405
9486
  });
9406
9487
  }
9407
9488
  loadTheme() {
9408
- const stored = localStorage.getItem(STORAGE_KEY);
9489
+ const stored = localStorage.getItem(STORAGE_KEY2);
9409
9490
  return stored || "auto";
9410
9491
  }
9411
9492
  saveTheme(theme) {
9412
- localStorage.setItem(STORAGE_KEY, theme);
9493
+ localStorage.setItem(STORAGE_KEY2, theme);
9413
9494
  }
9414
9495
  applyTheme(theme) {
9415
9496
  const resolvedTheme = this.resolveTheme(theme);
@@ -9507,6 +9588,14 @@ Duration: ${event.duration.toFixed(2)}ms`;
9507
9588
  defaultOptions: { queries: { staleTime: 3e4, retry: 1 } }
9508
9589
  });
9509
9590
  document.addEventListener("DOMContentLoaded", () => {
9591
+ const selectorEl = document.getElementById("profiler-cluster-selector");
9592
+ const isMaster = document.querySelector('meta[name="profiler-is-master"]')?.getAttribute("content") === "true";
9593
+ if (selectorEl && isMaster) {
9594
+ J(
9595
+ /* @__PURE__ */ u3(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ u3(ProfilerSelector, {}) }),
9596
+ selectorEl
9597
+ );
9598
+ }
9510
9599
  const indexEl = document.getElementById("profiler-index");
9511
9600
  if (indexEl) {
9512
9601
  J(
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class ClusterController < Profiler::ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def register
9
+ name = params[:name].to_s
10
+ url = params[:url].to_s
11
+
12
+ if name.empty? || url.empty?
13
+ return render json: { error: "name and url are required" }, status: :unprocessable_entity
14
+ end
15
+
16
+ Profiler.slave_registry.register(name: name, url: url)
17
+ render json: { ok: true, name: name }
18
+ end
19
+
20
+ def heartbeat
21
+ name = params[:name].to_s
22
+ entry = Profiler.slave_registry.heartbeat(name)
23
+ if entry.nil?
24
+ render json: { error: "Unknown slave — please re-register" }, status: :not_found
25
+ else
26
+ render json: { ok: true }
27
+ end
28
+ end
29
+
30
+ def slaves
31
+ render json: { slaves: Profiler.slave_registry.all }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class EventsController < Profiler::ApplicationController
6
+ include ActionController::Live
7
+ skip_before_action :verify_authenticity_token
8
+
9
+ def subscribe
10
+ response.headers["Content-Type"] = "text/event-stream"
11
+ response.headers["Cache-Control"] = "no-cache"
12
+ response.headers["X-Accel-Buffering"] = "no"
13
+
14
+ sse = ActionController::Live::SSE.new(response.stream, retry: 3000, event: "profile_update")
15
+ id = Profiler::SSE.current.subscribe(params[:token], Array(params[:collectors]))
16
+
17
+ begin
18
+ loop do
19
+ event = Profiler::SSE.current.wait_for_event(id, timeout: 30)
20
+ if event
21
+ sse.write(event)
22
+ else
23
+ sse.write({}, event: "heartbeat")
24
+ end
25
+ end
26
+ rescue ActionController::Live::ClientDisconnected, IOError
27
+ # Client disconnected — normal exit
28
+ ensure
29
+ Profiler::SSE.current.unsubscribe(id)
30
+ sse.close
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -9,8 +9,10 @@ module Profiler
9
9
  limit = (params[:limit] || 50).to_i
10
10
  offset = (params[:offset] || 0).to_i
11
11
  all = Profiler.storage.list(limit: 1000, offset: 0)
12
- http = all.select { |p| p.profile_type == "http" }
13
- page = http.drop(offset).first(limit + 1)
12
+ # all_types is an opt-in used by the cluster proxy so it can mirror the full
13
+ # storage.list contract; the dashboard relies on the default http-only filter.
14
+ scope = all_types? ? all : all.select { |p| p.profile_type == "http" }
15
+ page = scope.drop(offset).first(limit + 1)
14
16
  render json: {
15
17
  profiles: page.first(limit).map(&:to_h),
16
18
  limit: limit,
@@ -50,6 +52,10 @@ module Profiler
50
52
 
51
53
  private
52
54
 
55
+ def all_types?
56
+ %w[1 true].include?(params[:all_types].to_s)
57
+ end
58
+
53
59
  def recalculate_ajax_data(profile)
54
60
  # Find AJAX collector in the configured collectors
55
61
  ajax_collector_class = Profiler::Collectors::AjaxCollector
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../lib/profiler/cluster/slave_proxy"
4
+
5
+ module Profiler
6
+ module Api
7
+ class SlaveProxyController < Profiler::ApplicationController
8
+ skip_before_action :verify_authenticity_token
9
+
10
+ def forward
11
+ proxy = Cluster::SlaveProxy.new(params[:slave_name])
12
+ sub_path = params[:path].to_s
13
+ full_path = "/_profiler/api/#{sub_path}"
14
+
15
+ result = case request.method
16
+ when "GET"
17
+ proxy.get_json(full_path, request.query_parameters.to_h)
18
+ when "POST"
19
+ proxy.post_json(full_path, parsed_body)
20
+ when "PATCH"
21
+ proxy.patch_json(full_path, parsed_body)
22
+ when "DELETE"
23
+ proxy.delete_json(full_path)
24
+ else
25
+ return head :method_not_allowed
26
+ end
27
+
28
+ render json: result
29
+ rescue Profiler::Error => e
30
+ render json: { error: e.message }, status: :bad_gateway
31
+ rescue => e
32
+ render json: { error: "Proxy error: #{e.message}" }, status: :bad_gateway
33
+ end
34
+
35
+ private
36
+
37
+ def parsed_body
38
+ return {} if request.body.nil?
39
+
40
+ raw = request.body.read
41
+ return {} if raw.empty?
42
+
43
+ JSON.parse(raw)
44
+ rescue JSON::ParserError
45
+ {}
46
+ end
47
+ end
48
+ end
49
+ end
@@ -6,12 +6,15 @@
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
8
  <meta name="profiler-version" content="<%= Profiler::VERSION %>">
9
+ <meta name="profiler-name" content="<%= Profiler.configuration.resolved_name %>">
10
+ <meta name="profiler-is-master" content="<%= Profiler.configuration.master? %>">
9
11
 
10
12
  <link rel="stylesheet" href="/_profiler/assets/profiler.css">
11
13
  <script src="/_profiler/assets/profiler.js" defer></script>
12
14
  </head>
13
15
 
14
16
  <body>
17
+ <div id="profiler-cluster-selector"></div>
15
18
  <%= yield %>
16
19
  </body>
17
20
  </html>
data/config/routes.rb CHANGED
@@ -50,5 +50,16 @@ Profiler::Engine.routes.draw do
50
50
  get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
51
51
  get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
52
52
  delete "test_runner/runs/:id", to: "test_runner#destroy"
53
+ get "events/:token", to: "events#subscribe", as: :profile_events
54
+
55
+ # Cluster endpoints (master-side)
56
+ post "cluster/register", to: "cluster#register"
57
+ post "cluster/heartbeat", to: "cluster#heartbeat"
58
+ get "cluster/slaves", to: "cluster#slaves"
59
+
60
+ # Slave proxy — must be last to avoid shadowing other api routes
61
+ scope "/slaves/:slave_name" do
62
+ match "*path", to: "slave_proxy#forward", via: :all
63
+ end
53
64
  end
54
65
  end