Skip to content

Commit 29b85e4

Browse files
authored
Feature: add resources page (#820)
1 parent e292ecd commit 29b85e4

File tree

24 files changed

+934
-2
lines changed

24 files changed

+934
-2
lines changed

assets/app/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import CopyButton from './hooks/copy_button';
1313
import TraceBodySearchHighlight from './hooks/trace_body_search_highlight';
1414
import TraceLabelSearchHighlight from './hooks/trace_label_search_highlight';
1515
import AssignsBodySearchHighlight from './hooks/assigns_body_search_highlight';
16+
import ChartHook from './hooks/chart_hook';
1617

1718
import topbar from './vendor/topbar';
1819

@@ -38,6 +39,7 @@ function createHooks() {
3839
TraceBodySearchHighlight,
3940
TraceLabelSearchHighlight,
4041
AssignsBodySearchHighlight,
42+
ChartHook,
4143
};
4244
}
4345

assets/app/hooks/chart_hook.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Chart from 'chart.js/auto';
2+
3+
const MAX_DATA_POINTS = 50;
4+
5+
const DATASET_CONFIG = [
6+
{ key: 'memory', label: 'Memory', color: 'blue', hidden: false },
7+
{
8+
key: 'total_heap_size',
9+
label: 'Total Heap Size',
10+
color: 'red',
11+
hidden: true,
12+
},
13+
{ key: 'heap_size', label: 'Heap Size', color: 'green', hidden: true },
14+
{ key: 'stack_size', label: 'Stack Size', color: 'orange', hidden: true },
15+
{ key: 'reductions', label: 'Reductions', color: 'purple', hidden: true },
16+
{
17+
key: 'message_queue_len',
18+
label: 'Message Queue Length',
19+
color: 'cyan',
20+
hidden: true,
21+
},
22+
];
23+
24+
const ChartHook = {
25+
mounted() {
26+
this.initializeChart();
27+
this.setupEventHandlers();
28+
},
29+
30+
setupEventHandlers() {
31+
this.handleEvent('update-chart', (data) => {
32+
this.updateChartData(data);
33+
});
34+
},
35+
36+
initializeChart() {
37+
const style = getComputedStyle(document.documentElement);
38+
const primaryText = style.getPropertyValue('--primary-text').trim();
39+
const defaultBorder = style.getPropertyValue('--default-border').trim();
40+
41+
this.chart = new Chart(this.el, {
42+
type: 'line',
43+
data: {
44+
labels: [],
45+
datasets: this.createDatasets(),
46+
},
47+
options: this.createChartOptions(primaryText, defaultBorder),
48+
});
49+
},
50+
51+
createDatasets() {
52+
return DATASET_CONFIG.map(({ label, color, hidden }) => ({
53+
label,
54+
backgroundColor: color,
55+
borderColor: color,
56+
hidden,
57+
data: [],
58+
}));
59+
},
60+
61+
createChartOptions(primaryText, defaultBorder) {
62+
return {
63+
responsive: true,
64+
maintainAspectRatio: false,
65+
animation: false,
66+
plugins: {
67+
legend: {
68+
labels: {
69+
color: primaryText,
70+
},
71+
},
72+
},
73+
scales: {
74+
x: {
75+
ticks: { color: primaryText },
76+
grid: { color: defaultBorder },
77+
},
78+
y: {
79+
ticks: { color: primaryText },
80+
grid: { color: defaultBorder },
81+
beginAtZero: true,
82+
},
83+
},
84+
};
85+
},
86+
87+
updateChartData(data) {
88+
const timeString = this.formatTimestamp();
89+
this.chart.data.labels.push(timeString);
90+
91+
DATASET_CONFIG.forEach(({ key }, index) => {
92+
const value = Number(data[key]);
93+
this.chart.data.datasets[index].data.push(value);
94+
});
95+
96+
this.trimOldDataIfNeeded();
97+
this.chart.update('none');
98+
},
99+
100+
formatTimestamp() {
101+
return new Date().toLocaleTimeString('en-US', {
102+
hour: '2-digit',
103+
minute: '2-digit',
104+
second: '2-digit',
105+
hour12: false,
106+
});
107+
},
108+
109+
trimOldDataIfNeeded() {
110+
if (this.chart.data.labels.length > MAX_DATA_POINTS) {
111+
this.chart.data.labels.shift();
112+
this.chart.data.datasets.forEach((dataset) => dataset.data.shift());
113+
}
114+
},
115+
};
116+
117+
export default ChartHook;

assets/app/icons/chart-line.svg

Lines changed: 1 addition & 0 deletions
Loading

assets/app/icons/stopwatch.svg

Lines changed: 3 additions & 0 deletions
Loading

assets/app/package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
22
"dependencies": {
33
"@alpinejs/collapse": "^3.14.7",
4-
"@tailwindcss/forms": "^0.5.10",
54
"@tailwindcss/container-queries": "^0.1.1",
5+
"@tailwindcss/forms": "^0.5.10",
66
"alpinejs": "^3.14.7",
7+
"chart.js": "^4.5.1",
78
"phoenix": "^1.7.17",
89
"phoenix_html": "^3.3.4",
910
"phoenix_live_view": "^1.0.0"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule LiveDebugger.API.System.ProcessInfo do
2+
@moduledoc """
3+
This module provides wrappers for system functions that queries process information.
4+
"""
5+
6+
@callback get_info(pid :: pid()) :: {:ok, keyword()} | {:error, term()}
7+
8+
@doc """
9+
Wrapper for `:erlang.process_info/1`.
10+
Returns a keyword list of data items for the given process.
11+
"""
12+
13+
@spec get_info(pid :: pid()) :: {:ok, keyword()} | {:error, term()}
14+
def get_info(pid) when is_pid(pid) do
15+
impl().get_info(pid)
16+
end
17+
18+
defp impl() do
19+
Application.get_env(
20+
:live_debugger,
21+
:api_process_info,
22+
__MODULE__.Impl
23+
)
24+
end
25+
26+
defmodule Impl do
27+
@moduledoc false
28+
@behaviour LiveDebugger.API.System.ProcessInfo
29+
30+
@items_list ~w(
31+
current_function
32+
garbage_collection
33+
heap_size
34+
initial_call
35+
links
36+
memory
37+
message_queue_len
38+
monitored_by
39+
monitors
40+
priority
41+
reductions
42+
registered_name
43+
stack_size
44+
status
45+
suspending
46+
total_heap_size
47+
)a
48+
49+
@impl true
50+
def get_info(pid) when is_pid(pid) do
51+
pid
52+
|> :erlang.process_info(@items_list)
53+
|> case do
54+
info when is_list(info) -> {:ok, info}
55+
_ -> {:error, "Could not find process"}
56+
end
57+
end
58+
end
59+
end
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
defmodule LiveDebugger.App.Debugger.Resources.Components do
2+
@moduledoc false
3+
4+
use LiveDebugger.App.Web, :component
5+
6+
alias LiveDebugger.App.Web.LiveComponents.LiveDropdown
7+
alias LiveDebugger.App.Debugger.Resources.Structs.ProcessInfo
8+
alias LiveDebugger.Utils.Memory
9+
10+
@refresh_intervals [
11+
{"1 second", 1000},
12+
{"5 seconds", 5000},
13+
{"15 seconds", 15_000},
14+
{"30 seconds", 30_000}
15+
]
16+
17+
@keys_order ~w(
18+
initial_call
19+
current_function
20+
registered_name
21+
status
22+
message_queue_len
23+
priority
24+
reductions
25+
memory
26+
total_heap_size
27+
heap_size
28+
stack_size
29+
)a
30+
31+
@memory_keys ~w(memory total_heap_size heap_size stack_size)a
32+
33+
attr(:id, :string, required: true)
34+
attr(:name, :string, required: true)
35+
attr(:class, :string, default: "", doc: "Additional classes to add to the dropdown container")
36+
37+
attr(:selected_interval, :integer,
38+
required: true,
39+
doc: "Currently selected refresh interval in milliseconds"
40+
)
41+
42+
def refresh_select(assigns) do
43+
assigns = assign(assigns, :options, @refresh_intervals)
44+
45+
~H"""
46+
<.live_component module={LiveDropdown} id={@id} class={@class} direction={:bottom_left}>
47+
<:button>
48+
<.refresh_button selected_interval={@selected_interval} />
49+
</:button>
50+
<div class="min-w-44 flex flex-col p-2 gap-1">
51+
<.form for={%{}} phx-change="change-refresh-interval">
52+
<.radio_button
53+
:for={{label, value} <- @options}
54+
name={@name}
55+
value={value}
56+
label={label}
57+
checked={value == @selected_interval}
58+
/>
59+
</.form>
60+
</div>
61+
</.live_component>
62+
"""
63+
end
64+
65+
attr(:process_info, ProcessInfo, required: true)
66+
67+
def process_info(assigns) do
68+
assigns = assign(assigns, keys_order: @keys_order)
69+
70+
~H"""
71+
<div>
72+
<%= for key <- @keys_order do %>
73+
<div class="flex py-1">
74+
<span class="font-medium w-40 flex-shrink-0"><%= display_key(key) %>:</span>
75+
<span class={"font-code #{value_color_class(key)} truncate"}>
76+
<%= @process_info |> Map.get(key) |> display_value(key) %>
77+
</span>
78+
</div>
79+
<% end %>
80+
</div>
81+
"""
82+
end
83+
84+
attr(:selected_interval, :integer, required: true)
85+
86+
defp refresh_button(assigns) do
87+
~H"""
88+
<button
89+
aria-label="Refresh Rate"
90+
class={[
91+
"border border-default-border rounded-md p-2 text-accent-text flex items-center gap-1"
92+
]}
93+
>
94+
<.icon name="icon-stopwatch" class="h-4 w-4" />
95+
<span class="text-xs font-semibold">
96+
Refresh Rate (<%= Kernel.round(@selected_interval / 1000) %> s)
97+
</span>
98+
</button>
99+
"""
100+
end
101+
102+
defp display_key(:message_queue_len), do: "Message Queue Length"
103+
104+
defp display_key(key) do
105+
key
106+
|> to_string()
107+
|> String.replace(":", " ")
108+
|> String.split("_")
109+
|> Enum.map_join(" ", &String.capitalize/1)
110+
end
111+
112+
defp value_color_class(:message_queue_len), do: "text-code-1"
113+
defp value_color_class(:reductions), do: "text-code-1"
114+
defp value_color_class(:memory), do: "text-code-4"
115+
defp value_color_class(:total_heap_size), do: "text-code-4"
116+
defp value_color_class(:heap_size), do: "text-code-4"
117+
defp value_color_class(:stack_size), do: "text-code-4"
118+
defp value_color_class(_), do: "text-code-2"
119+
120+
defp display_value(mfa, :current_function), do: mfa_to_string(mfa)
121+
defp display_value(mfa, :initial_call), do: mfa_to_string(mfa)
122+
defp display_value(priority, :priority), do: "#{priority}"
123+
defp display_value(status, :status), do: "#{status}"
124+
defp display_value([], :registered_name), do: ""
125+
126+
defp display_value(size, key) when key in @memory_keys do
127+
Memory.bytes_to_pretty_string(size)
128+
end
129+
130+
defp display_value(value, _key), do: inspect(value)
131+
132+
defp mfa_to_string({module, function, arity}) do
133+
"#{inspect(module)}.#{function}/#{arity}"
134+
end
135+
end

0 commit comments

Comments
 (0)