├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .mise.toml
├── README.md
├── dev.sh
├── gleam.toml
├── manifest.toml
├── priv
├── lucy_spectator.svg
├── screenshot.png
└── styles.css
├── src
├── spectator.gleam
├── spectator
│ └── internal
│ │ ├── api.gleam
│ │ ├── common.gleam
│ │ ├── components
│ │ ├── dashboard_live.gleam
│ │ ├── ets_overview_live.gleam
│ │ ├── ets_table_live.gleam
│ │ ├── ports_live.gleam
│ │ └── processes_live.gleam
│ │ └── views
│ │ ├── charts.gleam
│ │ ├── display.gleam
│ │ ├── navbar.gleam
│ │ └── table.gleam
├── spectator_ffi.erl
└── spectator_tag_manager.erl
└── test
├── internal
└── api_local_test.gleam
├── playground.gleam
├── spectator_test.gleam
└── utils
└── pantry.gleam
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - main
8 | pull_request:
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: erlef/setup-beam@v1
16 | with:
17 | otp-version: "27"
18 | gleam-version: "1.8.1"
19 | rebar3-version: "3"
20 | # elixir-version: "1.15.4"
21 | - run: gleam deps download
22 | - run: gleam test
23 | - run: gleam format --check src test
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.beam
2 | *.ez
3 | /build
4 | erl_crash.dump
5 | chrome
6 | .DS_Store
--------------------------------------------------------------------------------
/.mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | erlang = "27"
3 | gleam = "1.8.1"
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ##
Spectator
2 | [](https://hex.pm/packages/spectator)
3 | [](https://hexdocs.pm/spectator/)
4 |
5 | Spectator is a BEAM observer tool written in Gleam, that plays well with gleam_otp processes.
6 |
7 | 
8 |
9 | ## Features
10 |
11 | This is a work in progress, so far it has the following features:
12 |
13 | - Show processes in a sortable table
14 | - Tag individual processes for easy identification
15 | - Show process details
16 | - Show OTP process state
17 | - Suspend / resume OTP processes
18 | - List ETS tables
19 | - View content of ETS tables
20 | - List of active ports
21 | - Clickable links between resources
22 | - Dashboard with basic statistics
23 | - Inspect other BEAM nodes
24 |
25 | ## Installation
26 |
27 | ```sh
28 | gleam add spectator
29 | ```
30 |
31 | ## Usage
32 |
33 | > 🦔 **Please be aware spectator is still new, and a work in progress, reliability is not guaranteed, use at your own peril!**
34 |
35 | Call `spectator.start()` in your application, to start the spectator service and WebUI.
36 |
37 | In order to make it easier to identify your Gleam processes in the process list, you can tag them with a friendly name with `spectator.tag`, `spectator.tag_subject` or `spectator.tag_result` **after** starting the spectator service.
38 |
39 | ## Example
40 |
41 | ```gleam
42 | import gleam/erlang/process
43 | import spectator
44 | import utils/pantry
45 |
46 | pub fn main() {
47 | // Start the spectator service
48 | let assert Ok(_) = spectator.start()
49 |
50 | // Start an OTP actor
51 | let assert Ok(sub) = pantry.new()
52 |
53 | // Tag the actor with a name for easier identification in the spectator UI
54 | // Note: this only works because we called spectator.start before!
55 | sub
56 | |> spectator.tag_subject("Pantry Actor")
57 |
58 | // Add some state to the actor
59 | pantry.add_item(sub, "This actor has some state")
60 | pantry.add_item(sub, "Another item in the state of this actor")
61 | pantry.add_item(sub, "And some more state I've put into this demo actor")
62 |
63 | // Sleep on the main process so the program doesn't exit
64 | process.sleep_forever()
65 | }
66 | ```
67 |
68 | ## Considerations
69 |
70 | Please be aware of the following implications of running spectator:
71 |
72 | * **Spectator may slow down your system**
73 | All displayed processes are probed in the configured interval using Erlang's `process_info/2` function which puts a temporary lock on the process being infoed. If the process is handling a lot of messages, this may have performance implications for the system
74 | * **Spectator will send system messages to selected processes**
75 | In order to get a selected processes OTP state, spectator needs to send it system messages. If you select a process that is not handling these messages properly, spectator may fill up its message queue, as it is sending a new message every tick in the configured interval. If the processes message queue is over a certain size, spectator will stop sending new messages, however the process may never handle the already queued up messages
76 | * **Spectator will create atoms dynamically**
77 | When you choose to connect to other Erlang nodes, spectator needs to convert the node name and cookie you provide into atoms. Therefore it is possible to exhaust the memory of the BEAM instance running spectator using its user interface, as atoms are never garbage collected.
78 |
79 |
80 | ----
81 |
82 | Further documentation can be found at .
83 |
--------------------------------------------------------------------------------
/dev.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | export ERL_FLAGS="-sname spectator"
3 | watchexec --restart --verbose --wrap-process=session --stop-signal SIGTERM --exts gleam --debounce 500ms --watch src/ -- "gleam run -m playground"
--------------------------------------------------------------------------------
/gleam.toml:
--------------------------------------------------------------------------------
1 | name = "spectator"
2 | version = "1.4.3"
3 | description = "BEAM observer tool"
4 | licences = ["MIT"]
5 | repository = { type = "github", user = "JonasGruenwald", repo = "spectator" }
6 |
7 | [dependencies]
8 | gleam_stdlib = ">= 0.47.0 and < 2.0.0"
9 | gleam_erlang = ">= 0.27.0 and < 1.0.0"
10 | gleam_otp = ">= 0.12.1 and < 1.0.0"
11 | lustre = ">= 4.5.1 and < 5.0.0"
12 | gleam_http = ">= 3.7.0 and < 4.0.0"
13 | mist = ">= 3.0.0 and < 5.0.0"
14 | simplifile = ">= 2.2.0 and < 3.0.0"
15 | pprint = ">= 1.0.3 and < 2.0.0"
16 | logging = ">= 1.3.0 and < 2.0.0"
17 | gleam_json = ">= 1.0.1 and < 3.0.0"
18 |
19 | [dev-dependencies]
20 | gleeunit = ">= 1.0.0 and < 2.0.0"
21 | carpenter = ">= 0.3.1 and < 1.0.0"
22 |
--------------------------------------------------------------------------------
/manifest.toml:
--------------------------------------------------------------------------------
1 | # This file was generated by Gleam
2 | # You typically do not need to edit this file
3 |
4 | packages = [
5 | { name = "carpenter", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "carpenter", source = "hex", outer_checksum = "7F5AF15A315CF32E8EDD0700BC1E6711618F8049AFE66DFCE82D1161B33F7F1B" },
6 | { name = "filepath", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "67A6D15FB39EEB69DD31F8C145BB5A421790581BD6AA14B33D64D5A55DBD6587" },
7 | { name = "glam", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "4932A2D139AB0389E149396407F89654928D7B815E212BB02F13C66F53B1BBA1" },
8 | { name = "gleam_crypto", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "8AE56026B3E05EBB1F076778478A762E9EB62B31AEEB4285755452F397029D22" },
9 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" },
10 | { name = "gleam_http", version = "3.7.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8A70D2F70BB7CFEB5DF048A2183FFBA91AF6D4CF5798504841744A16999E33D2" },
11 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" },
12 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" },
13 | { name = "gleam_stdlib", version = "0.55.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "32D8F4AE03771516950047813A9E359249BD9FBA5C33463FDB7B953D6F8E896B" },
14 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
15 | { name = "gleeunit", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "0E6C83834BA65EDCAAF4FE4FB94AC697D9262D83E6F58A750D63C9F6C8A9D9FF" },
16 | { name = "glisten", version = "7.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "1A53CF9FB3231A93FF7F1BD519A43DC968C1722F126CDD278403A78725FC5189" },
17 | { name = "gramps", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "630BDE35E465511945253A06EBCDE8D5E4B8B1988F4AC6B8FAC297DEF55B4CA2" },
18 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
19 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
20 | { name = "lustre", version = "4.6.4", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "CC59564624A4A1D855B5FEB55D979A072B328D0368E82A1639F180840D6288E9" },
21 | { name = "mist", version = "4.0.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "ED319E5A7F2056E08340B6976EA5E717F3C3BB36056219AF826D280D9C077952" },
22 | { name = "pprint", version = "1.0.4", build_tools = ["gleam"], requirements = ["glam", "gleam_stdlib"], otp_app = "pprint", source = "hex", outer_checksum = "C310A98BDC0995644847C3C8702DE19656D6BCD638B2A8A358B97824379ECAA1" },
23 | { name = "simplifile", version = "2.2.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0DFABEF7DC7A9E2FF4BB27B108034E60C81BEBFCB7AB816B9E7E18ED4503ACD8" },
24 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
25 | ]
26 |
27 | [requirements]
28 | carpenter = { version = ">= 0.3.1 and < 1.0.0" }
29 | gleam_erlang = { version = ">= 0.27.0 and < 1.0.0" }
30 | gleam_http = { version = ">= 3.7.0 and < 4.0.0" }
31 | gleam_json = { version = ">= 1.0.1 and < 3.0.0" }
32 | gleam_otp = { version = ">= 0.12.1 and < 1.0.0" }
33 | gleam_stdlib = { version = ">= 0.47.0 and < 2.0.0" }
34 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
35 | logging = { version = ">= 1.3.0 and < 2.0.0" }
36 | lustre = { version = ">= 4.5.1 and < 5.0.0" }
37 | mist = { version = ">= 3.0.0 and < 5.0.0" }
38 | pprint = { version = ">= 1.0.3 and < 2.0.0" }
39 | simplifile = { version = ">= 2.2.0 and < 3.0.0" }
40 |
--------------------------------------------------------------------------------
/priv/lucy_spectator.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/priv/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JonasGruenwald/spectator/1cfba7c353e7326f6c32b778cbddcd72bb3bb451/priv/screenshot.png
--------------------------------------------------------------------------------
/priv/styles.css:
--------------------------------------------------------------------------------
1 | /* Gloal */
2 | * {
3 | font-family: Arial, sans-serif;
4 | font-size: 14px;
5 | box-sizing: border-box;
6 | }
7 |
8 |
9 | :root {
10 | /* Default Theme */
11 | --text: #ffffff;
12 | --muted: #595959;
13 | --background: #2f2f2f;
14 | --highlight: #584355;
15 | --text-hightlight: #ffaff3;
16 |
17 | /* General */
18 | --unnamed-blue: #a6f0fc;
19 | --meter-bar: #ffaff3;
20 | }
21 |
22 | @media (prefers-color-scheme: light) {
23 | :root {
24 | --text: #2f2f2f;
25 | --muted: #d2d2d2;
26 | --background: #ffffff;
27 | --highlight: #fffbe8;
28 | --text-hightlight: rgb(171, 98, 160);
29 | }
30 | }
31 |
32 | @media (prefers-color-scheme: dark) {
33 | :root {
34 | --text: #ffffff;
35 | --muted: #595959;
36 | --background: #2f2f2f;
37 | --highlight: #584355;
38 | --text-hightlight: #ffaff3;
39 | }
40 | }
41 |
42 | *::selection {
43 | background: var(--text);
44 | color: var(--background);
45 | }
46 |
47 | *::-moz-selection {
48 | background: var(--text);
49 | color: var(--background);
50 | }
51 |
52 | input:focus{
53 | outline: none;
54 | }
55 |
56 | html,
57 | body {
58 | margin: 0;
59 | padding: 0;
60 | color: var(--text);
61 | background: var(--background);
62 | }
63 |
64 | body {
65 | overflow-y: scroll;
66 | }
67 |
68 | dl {
69 | margin: 5px;
70 | }
71 |
72 | dt {
73 | font-weight: bold;
74 | margin-bottom: 5px;
75 | }
76 |
77 | button,
78 | .button {
79 | background: var(--background);
80 | border: 1px solid var(--text);
81 | color: var(--text);
82 | padding: 1px 4px;
83 | text-decoration: none;
84 | }
85 |
86 | button:hover,
87 | .button:hover {
88 | background: var(--text);
89 | color: var(--background);
90 | }
91 |
92 | .interactive-primitive {
93 | cursor: pointer;
94 | background: unset;
95 | color: var(--text);
96 | text-decoration: underline;
97 | border: 0;
98 | padding: 0;
99 | }
100 |
101 | .interactive-primitive:hover {
102 | background: var(--text);
103 | color: var(--background);
104 | }
105 |
106 | .interactive-primitive.block {
107 | display: block;
108 | }
109 |
110 | .interactive-primitive.tagged {
111 | color: var(--text-hightlight);
112 | }
113 |
114 | .interactive-primitive.tagged:hover {
115 | color: var(--background);
116 | background: var(--text-hightlight);
117 | }
118 |
119 | /* Top Navigational Bar */
120 | .topbar {
121 | display: flex;
122 | font-weight: 100;
123 | align-items: flex-end;
124 | justify-content: space-between;
125 | user-select: none;
126 | }
127 |
128 | .lucy-icon {
129 | width: 3em;
130 | }
131 |
132 | .logo {
133 | font-size: 28px;
134 | display: flex;
135 | align-items: center;
136 | padding: 5px 2.5px;
137 | border-bottom: 1px solid var(--text);
138 | }
139 |
140 | .filler {
141 | height: 100%;
142 | flex-grow: 2;
143 | border-bottom: 1px solid var(--text);
144 | }
145 |
146 | .separator {
147 | height: 100%;
148 | width: 5px;
149 | border-bottom: 1px solid var(--text);
150 | }
151 |
152 | .tabs {
153 | display: flex;
154 | align-items: end;
155 | }
156 |
157 | .tab {
158 | display: flex;
159 | align-items: center;
160 | border: 1px solid var(--text);
161 | padding: 10px;
162 | border-top-right-radius: 10px;
163 | border-top-left-radius: 10px;
164 | user-select: none;
165 | cursor: pointer;
166 | text-decoration: none;
167 | color: var(--text);
168 | }
169 |
170 | .tab.disabled {
171 | cursor: not-allowed;
172 | }
173 |
174 | .tab.active {
175 | border-bottom: 1px solid var(--background);
176 | }
177 |
178 | .tab>svg,
179 | .tab>div {
180 | height: 3em;
181 | }
182 |
183 | .connection-status {
184 | border-bottom: 1px solid var(--text);
185 | display: flex;
186 | align-items: center;
187 | justify-content: center;
188 | padding: 20px;
189 | }
190 |
191 | /* Table View */
192 | table {
193 | width: 100%;
194 | min-height: 100vh;
195 | border-spacing: 0;
196 | }
197 |
198 | thead {
199 | position: sticky;
200 | top: 0;
201 | left: 0;
202 | z-index: 1;
203 | background: var(--background);
204 | user-select: none;
205 | }
206 |
207 | tbody {
208 | user-select: none;
209 | }
210 |
211 | thead th {
212 | border-bottom: 1px solid var(--text);
213 | }
214 |
215 | th:hover {
216 | background: var(--highlight);
217 | }
218 |
219 | th div {
220 | display: flex;
221 | align-items: center;
222 | }
223 |
224 | th div svg {
225 | margin-right: 5px;
226 | }
227 |
228 | tfoot {
229 | position: sticky;
230 | bottom: 0;
231 | left: 0;
232 | background: var(--background);
233 | }
234 |
235 | tfoot td {
236 | border-top: 1px solid var(--text);
237 | }
238 |
239 | th:first-child {
240 | border-left: 0;
241 | }
242 |
243 | .buffer-row {
244 | pointer-events: none;
245 | height: auto;
246 | }
247 |
248 | .buffer-row>td {
249 | height: auto;
250 | }
251 |
252 | th,
253 | td {
254 | text-align: left;
255 | padding: 2.5px;
256 | border: 0;
257 | max-width: 20vw;
258 | height: 1.6em;
259 | text-overflow: ellipsis;
260 | overflow: hidden;
261 | }
262 |
263 | td.link-cell {
264 | padding: 0;
265 | }
266 |
267 | td.link-cell>a {
268 | display: block;
269 | padding: 2.5px;
270 | }
271 |
272 | td.cell-right,
273 | th.cell-right {
274 | text-align: right;
275 | }
276 |
277 | td>a {
278 | text-decoration: none;
279 | color: var(--text);
280 | display: block;
281 | }
282 |
283 | tbody td:first-of-type,
284 | thead th:first-of-type {
285 | padding-left: 5px;
286 | }
287 |
288 | tbody td:last-of-type,
289 | thead th:last-of-type {
290 | padding-right: 5px;
291 | }
292 |
293 |
294 | tfoot th,
295 | tfoot td {
296 | padding: 0;
297 | }
298 |
299 | tbody tr:hover {
300 | background: var(--highlight)
301 | }
302 |
303 | tbody tr.selected {
304 | background: var(--text);
305 | color: var(--background);
306 | }
307 |
308 | tbody tr.selected .interactive-primitive {
309 | color: var(--background);
310 | }
311 |
312 | tbody tr.tagged {
313 | color: var(--text-hightlight);
314 | }
315 |
316 | .spectator-tagged {
317 | opacity: 0.5;
318 | }
319 |
320 | tbody tr.tagged.selected {
321 | background: var(--text-hightlight);
322 | color: var(--background);
323 | }
324 |
325 | .component-error {
326 | width: 100%;
327 | height: 50vh;
328 | display: flex;
329 | justify-content: center;
330 | align-items: center;
331 | display: flex;
332 | flex-direction: column;
333 | gap: 10px;
334 | }
335 |
336 | /* Process Details View */
337 | .details {
338 | display: grid;
339 | grid-template-columns: 1fr 1fr;
340 | grid-template-rows: auto 1fr;
341 | width: 100vw;
342 | overflow: hidden;
343 | }
344 |
345 | .details>div {
346 | height: 250px;
347 | overflow: auto;
348 | overscroll-behavior: none;
349 | }
350 |
351 | .panel-heading {
352 | padding: 5px;
353 | border-bottom: 1px solid var(--text);
354 | position: sticky;
355 | top: 0;
356 | left: 0;
357 | background: var(--background);
358 | box-sizing: content-box;
359 | height: 1.2em;
360 | display: flex;
361 | align-items: center;
362 | grid-column: span 2;
363 | }
364 |
365 | .panel-content {
366 | padding: 5px;
367 | overflow: auto;
368 | }
369 |
370 | .details .general {
371 | border-right: 1px solid var(--text);
372 | }
373 |
374 | .footer-placeholder {
375 | padding: 2px;
376 | text-align: right;
377 | }
378 |
379 | .panel-action {
380 | margin-left: auto;
381 | cursor: pointer;
382 | }
383 |
384 | /* ETS */
385 |
386 | table.ets-data td {
387 | border-bottom: 1px solid var(--muted);
388 | user-select: text;
389 | }
390 |
391 | table.ets-data tbody tr:hover {
392 | background: var(--background);
393 | }
394 |
395 | /* Ports */
396 |
397 | .details.compact {
398 | display: flex;
399 | }
400 |
401 | .details.compact>dl {
402 | flex-basis: 33%;
403 | }
404 |
405 | .details.compact dt {
406 | text-align: left;
407 | }
408 |
409 | .details.compact dd {
410 | margin-left: 0;
411 | }
412 |
413 | /* Dashboard */
414 |
415 | .dashboard {
416 | padding: 10px;
417 | }
418 |
419 | .split {
420 | display: flex;
421 | gap: 100px;
422 | width: 100%;
423 | }
424 |
425 | .split>div {
426 | flex-basis: 50%;
427 | }
428 |
429 | .info-container {
430 | border: 1px solid var(--text);
431 | }
432 |
433 | .info-item {
434 | display: flex;
435 | }
436 |
437 | .info-item {
438 | border-bottom: 1px solid var(--text);
439 | }
440 |
441 | .info-item:last-child {
442 | border-bottom: 0;
443 | }
444 |
445 | .info-item>* {
446 | flex-basis: 50%;
447 | padding: 5px;
448 | border: 0;
449 | background: var(--background);
450 | color: var(--text);
451 | }
452 |
453 | .info-item>a {
454 | text-align: center;
455 | user-select: none;
456 | }
457 |
458 | .info-item>a:first-child {
459 | border-right: 1px solid var(--text);
460 | }
461 |
462 | .info-label {
463 | border-right: 1px solid var(--text);
464 | text-align: right;
465 | }
466 |
467 | .column-chart,
468 | .meter-chart {
469 | padding: 2px;
470 | border: 1px solid var(--text);
471 | }
472 |
473 | .memory-breakdown {
474 | display: flex;
475 | gap: 10px
476 | }
477 |
478 | .limit-item {
479 | margin-bottom: 10px;
480 | }
481 |
482 | .legend-item {
483 | display: flex
484 | }
485 |
486 | .legend-colour {
487 | width: 2em;
488 | height: 2em;
489 | margin-right: 10px;
490 | padding: 2px;
491 | border: 1px solid var(--text);
492 | display: flex;
493 | }
494 |
495 | .legend-colour>div {
496 | flex-grow: 1;
497 | }
--------------------------------------------------------------------------------
/src/spectator.gleam:
--------------------------------------------------------------------------------
1 | import gleam/bool
2 | import gleam/bytes_tree
3 | import gleam/dynamic
4 | import gleam/erlang
5 | import gleam/erlang/atom
6 | import gleam/erlang/node
7 | import gleam/erlang/process
8 | import gleam/http
9 | import gleam/http/request.{type Request}
10 | import gleam/http/response.{type Response}
11 | import gleam/int
12 | import gleam/io
13 | import gleam/json
14 | import gleam/option
15 | import gleam/otp/actor
16 | import gleam/otp/static_supervisor as sup
17 | import gleam/result
18 | import gleam/uri
19 | import lustre
20 | import lustre/attribute
21 | import lustre/element
22 | import lustre/element/html.{html}
23 | import lustre/server_component
24 | import mist.{type Connection, type ResponseData, type WebsocketConnection}
25 | import spectator/internal/api
26 | import spectator/internal/common
27 | import spectator/internal/components/dashboard_live
28 | import spectator/internal/components/ets_overview_live
29 | import spectator/internal/components/ets_table_live
30 | import spectator/internal/components/ports_live
31 | import spectator/internal/components/processes_live
32 | import spectator/internal/views/navbar
33 |
34 | /// Entrypoint for running spectator from the command line.
35 | /// This will start the spectator application on port 3000 and never return.
36 | pub fn main() {
37 | let assert Ok(_) = start()
38 | process.sleep_forever()
39 | }
40 |
41 | fn start_server(port: Int) -> Result(process.Pid, Nil) {
42 | // Start mist server
43 | let empty_body = mist.Bytes(bytes_tree.new())
44 | let not_found = response.set_body(response.new(404), empty_body)
45 | let server_result =
46 | fn(req: Request(Connection)) -> Response(ResponseData) {
47 | let query_params = request.get_query(req) |> result.unwrap([])
48 | case request.path_segments(req) {
49 | // App Routes
50 | ["dashboard"] ->
51 | render_server_component("Dashboard", "dashboard-feed", query_params)
52 | ["processes"] ->
53 | render_server_component("Processes", "process-feed", query_params)
54 | ["ets"] -> render_server_component("ETS", "ets-feed", query_params)
55 | ["ets", table] ->
56 | render_server_component("ETS", "ets-feed/" <> table, query_params)
57 | ["ports"] -> render_server_component("Ports", "port-feed", query_params)
58 | // WebSocket Routes
59 | ["dashboard-feed"] ->
60 | connect_server_component(req, dashboard_live.app, query_params)
61 | ["process-feed"] ->
62 | connect_server_component(req, processes_live.app, query_params)
63 | ["ets-feed"] ->
64 | connect_server_component(req, ets_overview_live.app, query_params)
65 | ["ets-feed", table] ->
66 | connect_server_component(req, ets_table_live.app, [
67 | #("table_name", uri.percent_decode(table) |> result.unwrap("")),
68 | ..query_params
69 | ])
70 | ["port-feed"] ->
71 | connect_server_component(req, ports_live.app, query_params)
72 | // Static files
73 | ["favicon.svg"] -> {
74 | let assert Ok(priv) = erlang.priv_directory("spectator")
75 | let path = priv <> "/lucy_spectator.svg"
76 | mist.send_file(path, offset: 0, limit: option.None)
77 | |> result.map(fn(favicon) {
78 | response.new(200)
79 | |> response.prepend_header("content-type", "image/svg+xml")
80 | |> response.set_body(favicon)
81 | })
82 | |> result.lazy_unwrap(fn() {
83 | response.new(404)
84 | |> response.set_body(mist.Bytes(bytes_tree.new()))
85 | })
86 | }
87 | // Redirect to dashboard by default
88 | [] -> {
89 | response.new(302)
90 | |> response.prepend_header("location", "/dashboard")
91 | |> response.set_body(empty_body)
92 | }
93 |
94 | _ -> not_found
95 | }
96 | }
97 | |> mist.new
98 | |> mist.after_start(fn(port, scheme, interface) {
99 | let address = case interface {
100 | mist.IpV6(..) -> "[" <> mist.ip_address_to_string(interface) <> "]"
101 | _ -> mist.ip_address_to_string(interface)
102 | }
103 | let message =
104 | "🔍 Spectator is listening on "
105 | <> http.scheme_to_string(scheme)
106 | <> "://"
107 | <> address
108 | <> ":"
109 | <> int.to_string(port)
110 | <> " - Node: "
111 | <> atom.to_string(node.self() |> node.to_atom())
112 | io.println(message)
113 | })
114 | |> mist.port(port)
115 | |> mist.start_http
116 |
117 | // Extract PID for supervisor
118 | case server_result {
119 | Ok(server) -> {
120 | let server_pid = process.subject_owner(server)
121 | tag(server_pid, "__spectator_internal Server")
122 | Ok(server_pid)
123 | }
124 | Error(_e) -> {
125 | Error(Nil)
126 | }
127 | }
128 | }
129 |
130 | /// Start the spectator application on port 3000
131 | pub fn start() {
132 | start_on(3000)
133 | }
134 |
135 | pub fn start_on(port: Int) -> Result(process.Pid, dynamic.Dynamic) {
136 | sup.new(sup.OneForOne)
137 | |> sup.add(sup.worker_child("Spectator Tag Manager", api.start_tag_manager))
138 | |> sup.add(
139 | sup.worker_child("Spectator Mist Server", fn() { start_server(port) }),
140 | )
141 | |> sup.start_link()
142 | }
143 |
144 | /// Tag a process given by PID with a name for easier identification in the spectator UI.
145 | /// You must call `start` before calling this function.
146 | pub fn tag(pid: process.Pid, name: String) -> process.Pid {
147 | api.add_tag(pid, name)
148 | pid
149 | }
150 |
151 | /// Tag a process given by subject with a name for easier identification in the spectator UI.
152 | /// You must call `start` before calling this function.
153 | pub fn tag_subject(
154 | subject sub: process.Subject(a),
155 | name name: String,
156 | ) -> process.Subject(a) {
157 | let pid = process.subject_owner(sub)
158 | tag(pid, name)
159 | sub
160 | }
161 |
162 | /// Tag a process given by subject result with a name for easier identification in the spectator UI.
163 | /// You must call `start` before calling this function.
164 | pub fn tag_result(
165 | result: Result(process.Subject(a), b),
166 | name: String,
167 | ) -> Result(process.Subject(a), b) {
168 | case result {
169 | Ok(sub) -> Ok(tag_subject(sub, name))
170 | other -> other
171 | }
172 | }
173 |
174 | type NodeConnectionError {
175 | NotDistributedError
176 | FailedToSetCookieError
177 | FailedToConnectError
178 | }
179 |
180 | fn validate_node_connection(
181 | params: common.Params,
182 | ) -> Result(String, NodeConnectionError) {
183 | let node_res = common.get_param(params, "node")
184 | case node_res {
185 | // No node passed, that's fine, we'll just use the local node
186 | // no other checks are needed
187 | Error(_) -> Ok("")
188 | Ok(node) -> {
189 | let self = node.self() |> node.to_atom()
190 | use <- bool.guard(
191 | self == atom.create_from_string("nonode@nohost"),
192 | Error(NotDistributedError),
193 | )
194 |
195 | let node_atom = atom.create_from_string(node)
196 | let cookie_validation_passed = case common.get_param(params, "cookie") {
197 | // No cookie, validation passes
198 | Error(_) -> True
199 | Ok(cookie) -> {
200 | let cookie_atom = atom.create_from_string(cookie)
201 | result.unwrap(api.set_cookie(node_atom, cookie_atom), False)
202 | }
203 | }
204 |
205 | use <- bool.guard(
206 | !cookie_validation_passed,
207 | Error(FailedToSetCookieError),
208 | )
209 |
210 | use <- bool.guard(
211 | !result.unwrap(api.hidden_connect_node(node_atom), False),
212 | Error(FailedToConnectError),
213 | )
214 |
215 | Ok("🟢 " <> atom.to_string(node_atom))
216 | }
217 | }
218 | }
219 |
220 | fn render_server_component(
221 | title: String,
222 | server_component_path path: String,
223 | params params: common.Params,
224 | ) {
225 | let res = response.new(200)
226 | let styles = common.static_file("styles.css")
227 | let html = case validate_node_connection(params) {
228 | Ok(connection_name) -> {
229 | html([], [
230 | html.head([], [
231 | html.title([], title),
232 | server_component.script(),
233 | html.meta([attribute.attribute("charset", "utf-8")]),
234 | html.link([
235 | attribute.rel("icon"),
236 | attribute.href("/favicon.svg"),
237 | attribute.type_("image/svg+xml"),
238 | ]),
239 | html.style([], styles),
240 | ]),
241 | html.body([], [
242 | navbar.render(
243 | title,
244 | connection_name,
245 | common.sanitize_params(params)
246 | |> common.encode_params(),
247 | ),
248 | element.element(
249 | "lustre-server-component",
250 | [
251 | server_component.route(
252 | "/" <> path <> common.encode_params(params),
253 | ),
254 | ],
255 | [],
256 | ),
257 | ]),
258 | ])
259 | }
260 | Error(connection_error) -> {
261 | html([], [
262 | html.head([], [
263 | html.title([], title),
264 | html.meta([attribute.attribute("charset", "utf-8")]),
265 | html.link([
266 | attribute.rel("icon"),
267 | attribute.href("/favicon.svg"),
268 | attribute.type_("image/svg+xml"),
269 | ]),
270 | html.style([], styles),
271 | ]),
272 | html.body([], [
273 | navbar.render(
274 | title,
275 | "Connection Failed",
276 | common.sanitize_params(params)
277 | |> common.encode_params(),
278 | ),
279 | html.div([attribute.class("component-error")], [
280 | html.div([], [html.text("Node connection failed:")]),
281 | html.div([], [
282 | html.text(case connection_error {
283 | NotDistributedError ->
284 | "Node is not distributed, cannot connect to other nodes. Please start the spectator instance in distributed mode by setting a node name."
285 | FailedToSetCookieError ->
286 | "Failed to set cookie, could not apply the cookie to the node"
287 | FailedToConnectError ->
288 | "Failed to connect to node, please check the node name and cookie"
289 | }),
290 | ]),
291 | html.div([], [
292 | html.a([attribute.href("/"), attribute.class("button")], [
293 | html.text("Return to local node"),
294 | ]),
295 | ]),
296 | ]),
297 | ]),
298 | ])
299 | }
300 | }
301 | response.set_body(
302 | res,
303 | html
304 | |> element.to_document_string
305 | |> bytes_tree.from_string
306 | |> mist.Bytes,
307 | )
308 | }
309 |
310 | // SERVER COMPONENT WIRING ----------------------------------------------------
311 |
312 | fn connect_server_component(
313 | req: Request(Connection),
314 | lustre_application,
315 | params: common.Params,
316 | ) {
317 | let socket_init = fn(_conn: WebsocketConnection) {
318 | let self = process.new_subject()
319 | let app = lustre_application()
320 | let assert Ok(live_component) = lustre.start_actor(app, params)
321 | tag_subject(live_component, "__spectator_internal Server Component")
322 | process.send(
323 | live_component,
324 | server_component.subscribe(
325 | // server components can have many connected clients, so we need a way to
326 | // identify this client.
327 | "ws",
328 | process.send(self, _),
329 | ),
330 | )
331 |
332 | #(
333 | // we store the server component's `Subject` as this socket's state so we
334 | // can shut it down when the socket is closed.
335 | live_component,
336 | option.Some(process.selecting(process.new_selector(), self, fn(a) { a })),
337 | )
338 | }
339 |
340 | let socket_update = fn(live_component, conn: WebsocketConnection, msg) {
341 | case msg {
342 | mist.Text(json) -> {
343 | // we attempt to decode the incoming text as an action to send to our
344 | // server component runtime.
345 | let action = json.decode(json, server_component.decode_action)
346 |
347 | case action {
348 | Ok(action) -> process.send(live_component, action)
349 | Error(_) -> Nil
350 | }
351 |
352 | actor.continue(live_component)
353 | }
354 |
355 | mist.Binary(_) -> actor.continue(live_component)
356 | mist.Custom(patch) -> {
357 | let assert Ok(_) =
358 | patch
359 | |> server_component.encode_patch
360 | |> json.to_string
361 | |> mist.send_text_frame(conn, _)
362 |
363 | actor.continue(live_component)
364 | }
365 | mist.Closed | mist.Shutdown -> actor.Stop(process.Normal)
366 | }
367 | }
368 |
369 | let socket_close = fn(live_component) {
370 | process.send(live_component, lustre.shutdown())
371 | }
372 |
373 | mist.websocket(
374 | request: req,
375 | on_init: socket_init,
376 | on_close: socket_close,
377 | handler: socket_update,
378 | )
379 | }
380 |
--------------------------------------------------------------------------------
/src/spectator/internal/api.gleam:
--------------------------------------------------------------------------------
1 | import gleam/dynamic
2 | import gleam/erlang
3 | import gleam/erlang/atom
4 | import gleam/erlang/port
5 | import gleam/erlang/process
6 | import gleam/int
7 | import gleam/list
8 | import gleam/option.{type Option, None, Some}
9 | import gleam/order
10 | import gleam/result
11 | import gleam/string
12 | import gleam/uri
13 | import logging.{log}
14 | import spectator/internal/common
15 |
16 | pub type ErlangNode =
17 | Option(atom.Atom)
18 |
19 | pub type ErlangError {
20 | ErpcError(reason: atom.Atom)
21 | NotSupportedError
22 | BadArgumentError
23 | ReturnedUndefinedError
24 | NotFoundError
25 | NoInfoError
26 | DynamicError(reason: dynamic.Dynamic)
27 | }
28 |
29 | /// A tuple from Erlang that could be any size.
30 | /// Used to represent ETS table data as it is not guaranteed to have a uniform number of columns.
31 | pub type OpaqueTuple
32 |
33 | /// The "SysState" type from the Erlang `sys` module, as returned by this function:
34 | /// https://www.erlang.org/doc/apps/stdlib/sys.html#get_status/2
35 | pub type SysState {
36 | ProcessRunning
37 | ProcessSuspended
38 | }
39 |
40 | /// A box for a pid, port or nif resource.
41 | /// Used because some process info fields return lists of these types mixed together,
42 | /// we distinguish them in the Erlang ffi and put them into these boxes for matching.
43 | /// Used to describe items returned from
44 | /// https://www.erlang.org/doc/apps/erts/erlang.html#process_info/2
45 | /// as 'links', 'monitors' and 'monitored_by', also 'parent in 'get_details'
46 | pub type SystemPrimitive {
47 | ProcessPrimitive(
48 | pid: process.Pid,
49 | name: Option(atom.Atom),
50 | tag: Option(String),
51 | )
52 | PortPrimitive(port_id: port.Port, name: Option(atom.Atom))
53 | RemoteProcessPrimitive(name: atom.Atom, node: atom.Atom)
54 | NifResourcePrimitive(dynamic.Dynamic)
55 | }
56 |
57 | /// A table in the Erlang ETS system.
58 | /// Used as a box for information returned from
59 | /// https://www.erlang.org/doc/apps/stdlib/ets.html#info/2
60 | pub type Table {
61 | Table(
62 | id: erlang.Reference,
63 | name: atom.Atom,
64 | table_type: atom.Atom,
65 | size: Int,
66 | memory: Int,
67 | owner: SystemPrimitive,
68 | protection: atom.Atom,
69 | read_concurrency: Bool,
70 | write_concurrency: Bool,
71 | )
72 | }
73 |
74 | /// The data held by an ETS table.
75 | pub type TableData {
76 | TableData(
77 | /// Each entry in the content list is a row in the table.
78 | /// Typically, all rows will have the same number of columns, but this is not guaranteed,
79 | /// therefore each row is again a list of dynamic data.
80 | content: List(List(dynamic.Dynamic)),
81 | /// The maximum number of columns in any row.
82 | max_length: Int,
83 | )
84 | }
85 |
86 | /// Box for data returned by the `sys:get_status` function.
87 | /// https://www.erlang.org/doc/apps/stdlib/sys.html#get_status/2
88 | /// Data is a bit tricky to extract, so we keep a remaining untyped
89 | /// list of status_items, which could potentially be pretty printed.
90 | pub type ProcessOtpStatus {
91 | ProcessOtpStatus(
92 | pid: process.Pid,
93 | module: atom.Atom,
94 | parent: process.Pid,
95 | sys_state: SysState,
96 | status_items: List(dynamic.Dynamic),
97 | )
98 | }
99 |
100 | /// A box for the details retuned for an OTP-compatible process
101 | pub type OtpDetails {
102 | OtpDetails(pid: process.Pid, status: ProcessOtpStatus, state: dynamic.Dynamic)
103 | }
104 |
105 | /// An inspected process with associated information,
106 | /// boxed together with the process pid.
107 | pub type ProcessItem {
108 | ProcessItem(pid: process.Pid, info: ProcessInfo)
109 | }
110 |
111 | /// Information about a process, as returned by `process_info/2`
112 | pub type ProcessInfo {
113 | ProcessInfo(
114 | /// tuple of module, function, and arity.
115 | current_function: #(atom.Atom, atom.Atom, Int),
116 | /// tuple of module, function, and arity.
117 | initial_call: #(atom.Atom, atom.Atom, Int),
118 | registered_name: option.Option(atom.Atom),
119 | memory: Int,
120 | message_queue_len: Int,
121 | reductions: Int,
122 | tag: option.Option(String),
123 | status: atom.Atom,
124 | )
125 | }
126 |
127 | /// Detailed information about a process
128 | pub type ProcessDetails {
129 | ProcessDetails(
130 | messages: List(dynamic.Dynamic),
131 | links: List(SystemPrimitive),
132 | monitored_by: List(SystemPrimitive),
133 | monitors: List(SystemPrimitive),
134 | trap_exit: Bool,
135 | parent: Option(SystemPrimitive),
136 | )
137 | }
138 |
139 | /// An inspected port with associated information
140 | /// boxed together with the port id.
141 | pub type PortItem {
142 | PortItem(port_id: port.Port, info: PortInfo)
143 | }
144 |
145 | /// Information about a port, as returned by `port_info/2`
146 | pub type PortInfo {
147 | PortInfo(
148 | command_name: String,
149 | registered_name: Option(atom.Atom),
150 | connected_process: SystemPrimitive,
151 | os_pid: Option(Int),
152 | input: Int,
153 | output: Int,
154 | memory: Int,
155 | queue_size: Int,
156 | )
157 | }
158 |
159 | /// Detailed information about a port
160 | pub type PortDetails {
161 | PortDetails(
162 | links: List(SystemPrimitive),
163 | monitored_by: List(SystemPrimitive),
164 | monitors: List(SystemPrimitive),
165 | )
166 | }
167 |
168 | /// The criteria by which to sort the list of processes
169 | pub type ProcessSortCriteria {
170 | SortByProcessName
171 | SortByTag
172 | SortByCurrentFunction
173 | SortByProcessMemory
174 | SortByReductions
175 | SortByMessageQueue
176 | SortByProcessStatus
177 | }
178 |
179 | /// The criteria by which to sort the list of ETS tables
180 | pub type TableSortCriteria {
181 | SortByTableId
182 | SortByTableName
183 | SortByTableType
184 | SortByTableSize
185 | SortByTableMemory
186 | SortByTableOwner
187 | SortByTableProtection
188 | SortByTableReadConcurrency
189 | SortByTableWriteConcurrency
190 | }
191 |
192 | pub type PortSortCriteria {
193 | SortByPortName
194 | SortByPortCommand
195 | SortByPortConnectedProcess
196 | SortByPortOsPid
197 | SortByPortInput
198 | SortByPortOutput
199 | SortByPortMemory
200 | SortByPortQueueSize
201 | }
202 |
203 | pub type SortDirection {
204 | Ascending
205 | Descending
206 | }
207 |
208 | /// System memory information as returned by
209 | /// https://www.erlang.org/doc/apps/erts/erlang.html#memory/0
210 | /// The different values have the following relation to each other.
211 | /// Values beginning with an uppercase letter is not part of the result.
212 | /// ```
213 | /// total = processes + system
214 | /// processes = processes_used + ProcessesNotUsed
215 | /// system = atom + binary + code + ets + OtherSystem
216 | /// atom = atom_used + AtomNotUsed
217 | /// RealTotal = processes + RealSystem
218 | /// RealSystem = system + MissedSystem
219 | /// ```
220 | pub type MemoryStatistics {
221 | MemoryStatistics(
222 | total: Int,
223 | processes: Int,
224 | processes_used: Int,
225 | system: Int,
226 | atom: Int,
227 | atom_used: Int,
228 | binary: Int,
229 | code: Int,
230 | ets: Int,
231 | )
232 | }
233 |
234 | pub type SystemInfo {
235 | SystemInfo(
236 | uptime: String,
237 | architecure: String,
238 | erts_version: String,
239 | otp_release: String,
240 | schedulers: Int,
241 | schedulers_online: Int,
242 | atom_count: Int,
243 | atom_limit: Int,
244 | ets_count: Int,
245 | ets_limit: Int,
246 | port_count: Int,
247 | port_limit: Int,
248 | process_count: Int,
249 | process_limit: Int,
250 | )
251 | }
252 |
253 | // ------ SORTING
254 |
255 | pub fn invert_sort_direction(direction: SortDirection) -> SortDirection {
256 | case direction {
257 | Ascending -> Descending
258 | Descending -> Ascending
259 | }
260 | }
261 |
262 | fn apply_direction(order: order.Order, direction: SortDirection) -> order.Order {
263 | case direction {
264 | Ascending -> order
265 | Descending -> order.negate(order)
266 | }
267 | }
268 |
269 | fn compare_tag(a: ProcessItem, b: ProcessItem) -> order.Order {
270 | case a.info.tag, b.info.tag {
271 | Some(a_tag), Some(b_tag) -> string.compare(a_tag, b_tag)
272 | // Tags are greater than initial calls
273 | None, Some(_) -> order.Lt
274 | Some(_), None -> order.Gt
275 | // If no tag, we compare initial calls
276 | None, None ->
277 | string.compare(
278 | function_to_string(a.info.initial_call),
279 | function_to_string(b.info.initial_call),
280 | )
281 | }
282 | }
283 |
284 | fn compare_name(a: ProcessItem, b: ProcessItem) -> order.Order {
285 | case a.info.registered_name, b.info.registered_name {
286 | Some(a_name), Some(b_name) ->
287 | string.compare(atom.to_string(a_name), atom.to_string(b_name))
288 | // Names are greater than PIDs
289 | None, Some(_) -> order.Lt
290 | Some(_), None -> order.Gt
291 | // All PIDs are considered equal for sorting purposes
292 | None, None -> order.Eq
293 | }
294 | }
295 |
296 | pub fn sort_process_list(
297 | input: List(ProcessItem),
298 | criteria: ProcessSortCriteria,
299 | direction: SortDirection,
300 | ) -> List(ProcessItem) {
301 | case criteria {
302 | SortByProcessMemory -> {
303 | list.sort(input, fn(a, b) {
304 | int.compare(a.info.memory, b.info.memory) |> apply_direction(direction)
305 | })
306 | }
307 | SortByReductions -> {
308 | list.sort(input, fn(a, b) {
309 | int.compare(a.info.reductions, b.info.reductions)
310 | |> apply_direction(direction)
311 | })
312 | }
313 | SortByMessageQueue -> {
314 | list.sort(input, fn(a, b) {
315 | int.compare(a.info.message_queue_len, b.info.message_queue_len)
316 | |> apply_direction(direction)
317 | })
318 | }
319 | SortByCurrentFunction -> {
320 | list.sort(input, fn(a, b) {
321 | string.compare(
322 | function_to_string(a.info.current_function),
323 | function_to_string(b.info.current_function),
324 | )
325 | |> apply_direction(direction)
326 | })
327 | }
328 | SortByProcessName -> {
329 | list.sort(input, fn(a, b) {
330 | compare_name(a, b) |> apply_direction(direction)
331 | })
332 | }
333 | SortByTag -> {
334 | list.sort(input, fn(a, b) {
335 | compare_tag(a, b) |> apply_direction(direction)
336 | })
337 | }
338 | SortByProcessStatus -> {
339 | list.sort(input, fn(a, b) {
340 | string.compare(
341 | atom.to_string(a.info.status),
342 | atom.to_string(b.info.status),
343 | )
344 | |> apply_direction(direction)
345 | })
346 | }
347 | }
348 | }
349 |
350 | fn disgustingly_index_into_list_at(
351 | in list: List(a),
352 | get index: Int,
353 | ) -> Result(a, Nil) {
354 | case index >= 0 {
355 | True ->
356 | list
357 | |> list.drop(index)
358 | |> list.first
359 | False -> Error(Nil)
360 | }
361 | }
362 |
363 | pub fn sort_table_data(
364 | input: TableData,
365 | sort_column: Int,
366 | sort_direction: SortDirection,
367 | ) -> TableData {
368 | // see it, say it,
369 | let sorted =
370 | list.sort(input.content, fn(a, b) {
371 | // Ok, but why?
372 | // Tables can have a variable number of columns, so we have to use lists
373 | // I still want to be able to sort by column index, so I have to index into the list
374 | // -> Let's see how performances fares here, we could otherwise go for a map or erlang array
375 | let a_cell = disgustingly_index_into_list_at(a, sort_column)
376 | let b_cell = disgustingly_index_into_list_at(b, sort_column)
377 | case a_cell, b_cell {
378 | Ok(a), Ok(b) ->
379 | compare_dynamic_data(a, b)
380 | |> apply_direction(sort_direction)
381 | Ok(_a), Error(_b) -> order.Gt |> apply_direction(sort_direction)
382 | Error(_a), Ok(_b) -> order.Lt
383 | Error(_), Error(_) -> order.Eq |> apply_direction(sort_direction)
384 | }
385 | })
386 |
387 | TableData(..input, content: sorted)
388 | }
389 |
390 | pub fn sort_table_list(
391 | input: List(Table),
392 | criteria: TableSortCriteria,
393 | direction: SortDirection,
394 | ) -> List(Table) {
395 | case criteria {
396 | SortByTableId -> {
397 | list.sort(input, fn(a, b) {
398 | string.compare(string.inspect(a.id), string.inspect(b.id))
399 | |> apply_direction(direction)
400 | })
401 | }
402 | SortByTableName -> {
403 | list.sort(input, fn(a, b) {
404 | string.compare(atom.to_string(a.name), atom.to_string(b.name))
405 | |> apply_direction(direction)
406 | })
407 | }
408 | SortByTableType -> {
409 | list.sort(input, fn(a, b) {
410 | string.compare(
411 | atom.to_string(a.table_type),
412 | atom.to_string(b.table_type),
413 | )
414 | |> apply_direction(direction)
415 | })
416 | }
417 | SortByTableSize -> {
418 | list.sort(input, fn(a, b) {
419 | int.compare(a.size, b.size) |> apply_direction(direction)
420 | })
421 | }
422 | SortByTableMemory -> {
423 | list.sort(input, fn(a, b) {
424 | int.compare(a.memory, b.memory) |> apply_direction(direction)
425 | })
426 | }
427 | SortByTableOwner -> {
428 | list.sort(input, fn(a, b) {
429 | string.compare(string.inspect(a.owner), string.inspect(b.owner))
430 | |> apply_direction(direction)
431 | })
432 | }
433 | SortByTableProtection -> {
434 | list.sort(input, fn(a, b) {
435 | string.compare(
436 | atom.to_string(a.protection),
437 | atom.to_string(b.protection),
438 | )
439 | |> apply_direction(direction)
440 | })
441 | }
442 | SortByTableReadConcurrency -> {
443 | list.sort(input, fn(a, b) {
444 | common.bool_compare(a.read_concurrency, b.read_concurrency)
445 | |> apply_direction(direction)
446 | })
447 | }
448 | SortByTableWriteConcurrency -> {
449 | list.sort(input, fn(a, b) {
450 | common.bool_compare(a.write_concurrency, b.write_concurrency)
451 | |> apply_direction(direction)
452 | })
453 | }
454 | }
455 | }
456 |
457 | pub fn sort_port_list(
458 | input: List(PortItem),
459 | criteria: PortSortCriteria,
460 | direction: SortDirection,
461 | ) -> List(PortItem) {
462 | case criteria {
463 | SortByPortName -> {
464 | list.sort(input, fn(a, b) {
465 | case a.info.registered_name, b.info.registered_name {
466 | Some(a), Some(b) ->
467 | string.compare(atom.to_string(a), atom.to_string(b))
468 | Some(_), None -> order.Gt
469 | None, Some(_) -> order.Lt
470 | None, None -> order.Eq
471 | }
472 | })
473 | }
474 | SortByPortCommand -> {
475 | list.sort(input, fn(a, b) {
476 | string.compare(a.info.command_name, b.info.command_name)
477 | |> apply_direction(direction)
478 | })
479 | }
480 | SortByPortConnectedProcess -> {
481 | list.sort(input, fn(a, b) {
482 | string.compare(
483 | string.inspect(a.info.connected_process),
484 | string.inspect(b.info.connected_process),
485 | )
486 | |> apply_direction(direction)
487 | })
488 | }
489 | SortByPortOsPid -> {
490 | list.sort(input, fn(a, b) {
491 | case a.info.os_pid, b.info.os_pid {
492 | Some(a), Some(b) -> int.compare(a, b)
493 | Some(_), None -> order.Gt
494 | None, Some(_) -> order.Lt
495 | None, None -> order.Eq
496 | }
497 | |> apply_direction(direction)
498 | })
499 | }
500 | SortByPortInput -> {
501 | list.sort(input, fn(a, b) {
502 | int.compare(a.info.input, b.info.input) |> apply_direction(direction)
503 | })
504 | }
505 | SortByPortOutput -> {
506 | list.sort(input, fn(a, b) {
507 | int.compare(a.info.output, b.info.output) |> apply_direction(direction)
508 | })
509 | }
510 | SortByPortMemory -> {
511 | list.sort(input, fn(a, b) {
512 | int.compare(a.info.memory, b.info.memory) |> apply_direction(direction)
513 | })
514 | }
515 | SortByPortQueueSize -> {
516 | list.sort(input, fn(a, b) {
517 | int.compare(a.info.queue_size, b.info.queue_size)
518 | |> apply_direction(direction)
519 | })
520 | }
521 | }
522 | }
523 |
524 | // ------ DATA FETCHING AND PROCESSING
525 |
526 | // -------[PROCESS LIST]
527 |
528 | pub fn get_process_list(n: ErlangNode) -> List(ProcessItem) {
529 | case list_processes(n) {
530 | Ok(pids) -> {
531 | list.filter_map(pids, fn(pid) {
532 | case get_process_info(n, pid) {
533 | Error(e) -> Error(e)
534 | Ok(info) -> Ok(ProcessItem(pid, info))
535 | }
536 | })
537 | }
538 | Error(e) -> {
539 | log(
540 | logging.Alert,
541 | "Failed to list processes, error: " <> string.inspect(e),
542 | )
543 | []
544 | }
545 | }
546 | }
547 |
548 | @external(erlang, "spectator_ffi", "list_processes")
549 | pub fn list_processes(
550 | node: ErlangNode,
551 | ) -> Result(List(process.Pid), ErlangError)
552 |
553 | @external(erlang, "spectator_ffi", "get_process_info")
554 | pub fn get_process_info(
555 | node: ErlangNode,
556 | pid: process.Pid,
557 | ) -> Result(ProcessInfo, ErlangError)
558 |
559 | // -------[PROCESS DETAILS]
560 |
561 | @external(erlang, "spectator_ffi", "get_details")
562 | pub fn get_details(
563 | node: ErlangNode,
564 | pid: process.Pid,
565 | ) -> Result(ProcessDetails, ErlangError)
566 |
567 | // -------[OTP PROCESS DETAILS]
568 |
569 | pub fn request_otp_data(
570 | node: ErlangNode,
571 | proc: process.Pid,
572 | callback: fn(OtpDetails) -> Nil,
573 | ) {
574 | process.start(
575 | fn() {
576 | case get_status(node, proc, 100) {
577 | Error(_) -> {
578 | Nil
579 | }
580 | Ok(status) -> {
581 | let state =
582 | get_state(node, proc, 100)
583 | |> result.unwrap(dynamic.from(option.None))
584 | callback(OtpDetails(pid: proc, status:, state:))
585 | }
586 | }
587 | },
588 | False,
589 | )
590 | }
591 |
592 | @external(erlang, "spectator_ffi", "get_status")
593 | pub fn get_status(
594 | node: ErlangNode,
595 | pid: process.Pid,
596 | timeout: Int,
597 | ) -> Result(ProcessOtpStatus, ErlangError)
598 |
599 | @external(erlang, "spectator_ffi", "get_state")
600 | pub fn get_state(
601 | node: ErlangNode,
602 | pid: process.Pid,
603 | timeout: Int,
604 | ) -> Result(dynamic.Dynamic, ErlangError)
605 |
606 | // -------[ETS]
607 |
608 | pub fn get_ets_data(node: ErlangNode, table: Table) {
609 | use raw_data <- result.try(get_raw_ets_data(node, table.id))
610 | process_raw_ets_data(raw_data, [], 0)
611 | }
612 |
613 | fn process_raw_ets_data(
614 | remainder: List(List(OpaqueTuple)),
615 | accumulator: List(List(dynamic.Dynamic)),
616 | max_length: Int,
617 | ) -> Result(TableData, ErlangError) {
618 | case remainder {
619 | [] -> {
620 | Ok(TableData(content: accumulator, max_length: max_length))
621 | }
622 | [[tup], ..rest] -> {
623 | let converted = opaque_tuple_to_list(tup)
624 | process_raw_ets_data(
625 | rest,
626 | [converted, ..accumulator],
627 | int.max(max_length, list.length(converted)),
628 | )
629 | }
630 | _ -> {
631 | Error(BadArgumentError)
632 | }
633 | }
634 | }
635 |
636 | @external(erlang, "spectator_ffi", "list_ets_tables")
637 | pub fn list_ets_tables(node: ErlangNode) -> Result(List(Table), ErlangError)
638 |
639 | @external(erlang, "spectator_ffi", "get_ets_table_info")
640 | pub fn get_ets_table_info(
641 | node: ErlangNode,
642 | name: atom.Atom,
643 | ) -> Result(Table, ErlangError)
644 |
645 | @external(erlang, "spectator_ffi", "get_ets_data")
646 | pub fn get_raw_ets_data(
647 | node: ErlangNode,
648 | table: erlang.Reference,
649 | ) -> Result(List(List(OpaqueTuple)), ErlangError)
650 |
651 | @external(erlang, "spectator_ffi", "opaque_tuple_to_list")
652 | pub fn opaque_tuple_to_list(tuple: OpaqueTuple) -> List(dynamic.Dynamic)
653 |
654 | // -------[PORTS]
655 |
656 | pub fn get_port_list(node: ErlangNode) -> List(PortItem) {
657 | list_ports(node)
658 | |> result.unwrap([])
659 | |> list.filter_map(fn(pid) {
660 | case get_port_info(node, pid) {
661 | Error(e) -> Error(e)
662 | Ok(info) -> Ok(PortItem(pid, info))
663 | }
664 | })
665 | }
666 |
667 | @external(erlang, "spectator_ffi", "list_ports")
668 | pub fn list_ports(node: ErlangNode) -> Result(List(port.Port), ErlangError)
669 |
670 | @external(erlang, "spectator_ffi", "get_port_info")
671 | pub fn get_port_info(
672 | node: ErlangNode,
673 | port: port.Port,
674 | ) -> Result(PortInfo, ErlangError)
675 |
676 | @external(erlang, "spectator_ffi", "get_port_details")
677 | pub fn get_port_details(
678 | node: ErlangNode,
679 | port: port.Port,
680 | ) -> Result(PortDetails, ErlangError)
681 |
682 | // ------- [SYSTEM STATISTICS]
683 |
684 | @external(erlang, "spectator_ffi", "get_memory_statistics")
685 | pub fn get_memory_statistics(
686 | node: ErlangNode,
687 | ) -> Result(MemoryStatistics, ErlangError)
688 |
689 | // ------ FORMATTING
690 |
691 | fn function_to_string(f: #(atom.Atom, atom.Atom, Int)) {
692 | atom.to_string(f.0) <> atom.to_string(f.1) <> int.to_string(f.2)
693 | }
694 |
695 | @external(erlang, "spectator_ffi", "format_pid")
696 | pub fn format_pid(pid: process.Pid) -> String
697 |
698 | @external(erlang, "spectator_ffi", "format_port")
699 | pub fn format_port(port: port.Port) -> String
700 |
701 | // ------- SYSTEM INTERACTION
702 |
703 | @external(erlang, "spectator_ffi", "kill_process")
704 | pub fn kill_process(
705 | node: ErlangNode,
706 | pid: process.Pid,
707 | ) -> Result(Bool, ErlangError)
708 |
709 | @external(erlang, "spectator_ffi", "sys_suspend")
710 | pub fn suspend(
711 | node: ErlangNode,
712 | pid: process.Pid,
713 | ) -> Result(dynamic.Dynamic, ErlangError)
714 |
715 | @external(erlang, "spectator_ffi", "sys_resume")
716 | pub fn resume(
717 | node: ErlangNode,
718 | pid: process.Pid,
719 | ) -> Result(dynamic.Dynamic, ErlangError)
720 |
721 | @external(erlang, "spectator_ffi", "new_ets_table")
722 | pub fn new_ets_table(
723 | node: ErlangNode,
724 | name: atom.Atom,
725 | ) -> Result(atom.Atom, ErlangError)
726 |
727 | @external(erlang, "spectator_ffi", "pid_to_string")
728 | fn pid_to_string(pid: process.Pid) -> String
729 |
730 | pub fn serialize_pid(pid: process.Pid) -> String {
731 | pid_to_string(pid)
732 | |> uri.percent_encode
733 | }
734 |
735 | @external(erlang, "spectator_ffi", "port_to_string")
736 | fn port_to_string(port: port.Port) -> String
737 |
738 | pub fn serialize_port(port: port.Port) -> String {
739 | port_to_string(port)
740 | |> uri.percent_encode
741 | }
742 |
743 | @external(erlang, "spectator_ffi", "pid_from_string")
744 | fn pid_from_string(string: String) -> Result(process.Pid, Nil)
745 |
746 | pub fn decode_pid(string: String) -> Result(process.Pid, Nil) {
747 | use decoded <- result.try(uri.percent_decode(string))
748 | pid_from_string(decoded)
749 | }
750 |
751 | @external(erlang, "spectator_ffi", "port_from_string")
752 | fn port_from_string(string: String) -> Result(port.Port, Nil)
753 |
754 | pub fn decode_port(string: String) -> Result(port.Port, Nil) {
755 | use decoded <- result.try(uri.percent_decode(string))
756 | port_from_string(decoded)
757 | }
758 |
759 | // ------- SYSTEM INFORMATION
760 |
761 | @external(erlang, "spectator_ffi", "compare_data")
762 | pub fn compare_dynamic_data(
763 | a: dynamic.Dynamic,
764 | b: dynamic.Dynamic,
765 | ) -> order.Order
766 |
767 | @external(erlang, "spectator_ffi", "get_word_size")
768 | pub fn get_word_size(node: ErlangNode) -> Result(Int, ErlangError)
769 |
770 | @external(erlang, "spectator_ffi", "get_system_info")
771 | pub fn get_system_info(node: ErlangNode) -> Result(SystemInfo, ErlangError)
772 |
773 | // ------- TAG MANAGER GEN_SERVER
774 |
775 | @external(erlang, "spectator_tag_manager", "start_link")
776 | pub fn start_tag_manager() -> Result(process.Pid, dynamic.Dynamic)
777 |
778 | @external(erlang, "spectator_tag_manager", "add_tag")
779 | pub fn add_tag(pid: process.Pid, tag: String) -> Nil
780 |
781 | @external(erlang, "spectator_tag_manager", "get_tag")
782 | pub fn get_tag(pid: process.Pid) -> Option(String)
783 |
784 | // ------- DISTRIBUTION
785 |
786 | @external(erlang, "spectator_ffi", "hidden_connect_node")
787 | pub fn hidden_connect_node(node: atom.Atom) -> Result(Bool, ErlangError)
788 |
789 | @external(erlang, "net_kernel", "connect_node")
790 | pub fn connect_node(node: atom.Atom) -> Bool
791 |
792 | @external(erlang, "spectator_ffi", "set_cookie")
793 | pub fn set_cookie(
794 | node: atom.Atom,
795 | cookie: atom.Atom,
796 | ) -> Result(Bool, ErlangError)
797 |
798 | pub fn node_from_params(params: common.Params) {
799 | case common.get_param(params, "node") {
800 | Ok(node_raw) -> Some(atom.create_from_string(node_raw))
801 | Error(_) -> None
802 | }
803 | }
804 |
--------------------------------------------------------------------------------
/src/spectator/internal/common.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang
2 | import gleam/erlang/process
3 | import gleam/int
4 | import gleam/list
5 | import gleam/option.{type Option, None, Some}
6 | import gleam/order
7 | import gleam/uri
8 | import lustre/effect
9 | import lustre/server_component
10 | import simplifile
11 |
12 | pub const message_queue_threshold = 10
13 |
14 | pub const refresh_interval_default = 1000
15 |
16 | pub const colour_process = "#EF5976"
17 |
18 | pub const colour_code = "#FDA870"
19 |
20 | pub const colour_ets = "#ECE27C"
21 |
22 | pub const colour_atom = "#95EA8C"
23 |
24 | pub const colour_binary = "#91D0DA"
25 |
26 | pub const colour_other = "#B498F6"
27 |
28 | pub type Params =
29 | List(#(String, String))
30 |
31 | pub fn encode_params(params: Params) -> String {
32 | case params {
33 | [] -> ""
34 | _ -> "?" <> uri.query_to_string(params)
35 | }
36 | }
37 |
38 | pub fn add_param(params: Params, key: String, value: String) -> Params {
39 | case value {
40 | "" -> params
41 | _ -> [#(key, value), ..params]
42 | }
43 | }
44 |
45 | pub fn get_param(params: Params, key: String) -> Result(String, Nil) {
46 | list.find_map(params, fn(p) {
47 | case p {
48 | #(k, "") if k == key -> Error(Nil)
49 | #(k, v) if k == key -> Ok(v)
50 | _ -> Error(Nil)
51 | }
52 | })
53 | }
54 |
55 | pub fn sanitize_params(params: Params) -> Params {
56 | list.filter_map(params, fn(p) {
57 | case p {
58 | #("node", _) -> Ok(p)
59 | #("cookie", _) -> Ok(p)
60 | #("refresh", _) -> Ok(p)
61 | _ -> Error(Nil)
62 | }
63 | })
64 | }
65 |
66 | pub fn get_refresh_interval(params: Params) -> Int {
67 | case get_param(params, "refresh") {
68 | Ok(rate) ->
69 | case int.parse(rate) {
70 | Ok(r) -> r
71 | Error(_) -> refresh_interval_default
72 | }
73 | Error(_) -> refresh_interval_default
74 | }
75 | }
76 |
77 | @external(erlang, "spectator_ffi", "truncate_float")
78 | pub fn truncate_float(value: Float) -> String
79 |
80 | pub fn format_percentage(value: Float) -> String {
81 | truncate_float(value) <> "%"
82 | }
83 |
84 | pub fn static_file(name: String) {
85 | let assert Ok(priv) = erlang.priv_directory("spectator")
86 | let assert Ok(data) = simplifile.read(priv <> "/" <> name)
87 | data
88 | }
89 |
90 | pub fn emit_after(
91 | delay: Int,
92 | msg: a,
93 | subject: Option(process.Subject(a)),
94 | subject_created_message: fn(process.Subject(a)) -> a,
95 | ) -> effect.Effect(a) {
96 | case subject {
97 | Some(self) -> {
98 | use _ <- effect.from
99 | let _ = process.send_after(self, delay, msg)
100 | Nil
101 | }
102 | None -> {
103 | use dispatch, subject <- server_component.select
104 | let selector =
105 | process.new_selector() |> process.selecting(subject, fn(msg) { msg })
106 | let _ = process.send_after(subject, delay, msg)
107 | dispatch(subject_created_message(subject))
108 | selector
109 | }
110 | }
111 | }
112 |
113 | pub fn bool_compare(a: Bool, with b: Bool) -> order.Order {
114 | case a, b {
115 | True, True -> order.Eq
116 | True, False -> order.Gt
117 | False, False -> order.Eq
118 | False, True -> order.Lt
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/spectator/internal/components/dashboard_live.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/atom
2 | import gleam/erlang/process
3 | import gleam/int
4 | import gleam/option.{type Option, None, Some}
5 | import gleam/result
6 | import lustre
7 | import lustre/attribute
8 | import lustre/effect
9 | import lustre/element.{type Element}
10 | import lustre/element/html
11 | import lustre/event
12 | import spectator/internal/api
13 | import spectator/internal/common
14 | import spectator/internal/views/charts.{ChartSegment}
15 | import spectator/internal/views/display
16 |
17 | // MAIN ------------------------------------------------------------------------
18 |
19 | pub fn app() {
20 | lustre.application(init, update, view)
21 | }
22 |
23 | // MODEL -----------------------------------------------------------------------
24 |
25 | pub opaque type Model {
26 | Model(
27 | node: api.ErlangNode,
28 | params: common.Params,
29 | subject: Option(process.Subject(Msg)),
30 | refresh_interval: Int,
31 | memory_stats: Option(api.MemoryStatistics),
32 | memory_relative: Option(RelativeMemoryStatistics),
33 | system_info: Option(api.SystemInfo),
34 | system_limits: Option(RelativeSystemLimits),
35 | node_input: String,
36 | cookie_input: String,
37 | refresh_input: String,
38 | )
39 | }
40 |
41 | /// These are in percent and add up to 100
42 | /// Except for `other_absolute` which is in bytes
43 | /// and represents the missin 'other' part of system
44 | /// which is not covered by the known categories
45 | type RelativeMemoryStatistics {
46 | RelativeMemoryStatistics(
47 | processes: Float,
48 | code: Float,
49 | ets: Float,
50 | atom: Float,
51 | binary: Float,
52 | other: Float,
53 | other_absolute: Int,
54 | )
55 | }
56 |
57 | type RelativeSystemLimits {
58 | RelativeSystemLimits(processes: Float, ports: Float, atoms: Float, ets: Float)
59 | }
60 |
61 | fn init(params: common.Params) -> #(Model, effect.Effect(Msg)) {
62 | let node = api.node_from_params(params)
63 | let node_input = case node {
64 | Some(n) -> atom.to_string(n)
65 | None -> ""
66 | }
67 | let refresh_interval = common.get_refresh_interval(params)
68 | let cookie_input = common.get_param(params, "cookie") |> result.unwrap("")
69 | let initial_model =
70 | do_refresh(Model(
71 | node:,
72 | params: common.sanitize_params(params),
73 | subject: None,
74 | refresh_interval:,
75 | memory_stats: None,
76 | memory_relative: None,
77 | system_info: None,
78 | system_limits: None,
79 | node_input: node_input,
80 | cookie_input: cookie_input,
81 | refresh_input: int.to_string(refresh_interval),
82 | ))
83 | #(
84 | initial_model,
85 | common.emit_after(
86 | initial_model.refresh_interval,
87 | Refresh,
88 | option.None,
89 | CreatedSubject,
90 | ),
91 | )
92 | }
93 |
94 | // UPDATE ----------------------------------------------------------------------
95 |
96 | pub opaque type Msg {
97 | Refresh
98 | CreatedSubject(process.Subject(Msg))
99 | NodeInputChanged(String)
100 | CookieInputChanged(String)
101 | RefreshInputChanged(String)
102 | }
103 |
104 | fn get_relative_memory_stats(input: api.MemoryStatistics) {
105 | let factor = 100.0 /. int.to_float(input.total)
106 | let processes = int.to_float(input.processes) *. factor
107 | let code = int.to_float(input.code) *. factor
108 | let ets = int.to_float(input.ets) *. factor
109 | let atom = int.to_float(input.atom) *. factor
110 | let binary = int.to_float(input.binary) *. factor
111 | let other = 100.0 -. processes -. code -. ets -. atom -. binary
112 |
113 | RelativeMemoryStatistics(
114 | processes:,
115 | code:,
116 | ets:,
117 | atom:,
118 | binary:,
119 | other:,
120 | other_absolute: input.system
121 | - input.atom
122 | - input.binary
123 | - input.code
124 | - input.ets,
125 | )
126 | }
127 |
128 | fn get_relative_system_limits(input: api.SystemInfo) {
129 | let processes =
130 | int.to_float(input.process_count)
131 | /. int.to_float(input.process_limit)
132 | *. 100.0
133 | let ports =
134 | int.to_float(input.port_count) /. int.to_float(input.port_limit) *. 100.0
135 | let atoms =
136 | int.to_float(input.atom_count) /. int.to_float(input.atom_limit) *. 100.0
137 | let ets =
138 | int.to_float(input.ets_count) /. int.to_float(input.ets_limit) *. 100.0
139 |
140 | RelativeSystemLimits(processes:, ports:, atoms:, ets:)
141 | }
142 |
143 | fn do_refresh(model: Model) -> Model {
144 | let memory_stats =
145 | api.get_memory_statistics(model.node)
146 | |> option.from_result
147 | let memory_relative = case memory_stats {
148 | None -> None
149 | Some(stats) -> Some(get_relative_memory_stats(stats))
150 | }
151 | let system_info =
152 | api.get_system_info(model.node)
153 | |> option.from_result
154 | let system_limits = case system_info {
155 | None -> None
156 | Some(info) -> Some(get_relative_system_limits(info))
157 | }
158 | Model(..model, memory_stats:, memory_relative:, system_info:, system_limits:)
159 | }
160 |
161 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
162 | case msg {
163 | NodeInputChanged(n) -> #(Model(..model, node_input: n), effect.none())
164 | CookieInputChanged(c) -> #(Model(..model, cookie_input: c), effect.none())
165 | RefreshInputChanged(r) -> #(Model(..model, refresh_input: r), effect.none())
166 | Refresh -> {
167 | #(
168 | do_refresh(model),
169 | common.emit_after(
170 | model.refresh_interval,
171 | Refresh,
172 | model.subject,
173 | CreatedSubject,
174 | ),
175 | )
176 | }
177 | CreatedSubject(subject) -> #(
178 | Model(..model, subject: Some(subject)),
179 | effect.none(),
180 | )
181 | }
182 | }
183 |
184 | // VIEW ------------------------------------------------------------------------
185 |
186 | fn view(model: Model) -> Element(Msg) {
187 | {
188 | use memory_stats <- option.then(model.memory_stats)
189 | use memory_relative <- option.then(model.memory_relative)
190 | use system_info <- option.then(model.system_info)
191 | use system_limits <- option.then(model.system_limits)
192 |
193 | Some(
194 | html.div([attribute.class("dashboard")], [
195 | split_section(
196 | [
197 | // Left
198 | html.h1([], [html.text("Inspect BEAM Nodes")]),
199 | html.div([attribute.class("info-container")], [
200 | // info_item("Currently Inspecting", case model.node {
201 | // Some(n) -> atom.to_string(n)
202 | // None -> "Self (The node running spectator)"
203 | // }),
204 | info_field(
205 | "Node",
206 | case model.node_input {
207 | "" -> "Enter node name..."
208 | n -> n
209 | },
210 | NodeInputChanged,
211 | ),
212 | info_field(
213 | "Cookie",
214 | case model.cookie_input {
215 | "" -> "Enter cookie or leave empty"
216 | n -> n
217 | },
218 | CookieInputChanged,
219 | ),
220 | info_field(
221 | "Refresh Interval (ms)",
222 | model.refresh_input,
223 | RefreshInputChanged,
224 | ),
225 | target_actions(model),
226 | ]),
227 | html.h1([], [html.text("System Information")]),
228 | html.div([attribute.class("info-container")], [
229 | info_item("Uptime", system_info.uptime),
230 | info_item("System Architecture", system_info.architecure),
231 | info_item("Runtime Version", system_info.erts_version),
232 | info_item("OTP Release", system_info.otp_release),
233 | info_item(
234 | "Schedulers",
235 | int.to_string(system_info.schedulers_online)
236 | <> " / "
237 | <> int.to_string(system_info.schedulers),
238 | ),
239 | ]),
240 | ],
241 | [
242 | // Right
243 | html.h1([], [html.text("System Limits")]),
244 | charts.meter(system_limits.processes),
245 | limit_label(
246 | "Processes",
247 | system_info.process_count,
248 | system_info.process_limit,
249 | system_limits.processes,
250 | ),
251 | charts.meter(system_limits.ports),
252 | limit_label(
253 | "Ports",
254 | system_info.port_count,
255 | system_info.port_limit,
256 | system_limits.ports,
257 | ),
258 | charts.meter(system_limits.atoms),
259 | limit_label(
260 | "Atoms",
261 | system_info.atom_count,
262 | system_info.atom_limit,
263 | system_limits.atoms,
264 | ),
265 | charts.meter(system_limits.ets),
266 | limit_label(
267 | "ETS",
268 | system_info.ets_count,
269 | system_info.ets_limit,
270 | system_limits.ets,
271 | ),
272 | ],
273 | ),
274 | render_memory_section(memory_stats, memory_relative),
275 | ]),
276 | )
277 | }
278 | |> option.unwrap(
279 | html.div([attribute.class("component-error")], [
280 | html.text("Could not fetch dashboard data"),
281 | ]),
282 | )
283 | }
284 |
285 | fn limit_label(label: String, value: Int, limit: Int, percent: Float) {
286 | html.div([attribute.class("limit-item")], [
287 | html.text(
288 | label
289 | <> ": "
290 | <> int.to_string(value)
291 | <> " / "
292 | <> int.to_string(limit)
293 | <> " ("
294 | <> common.format_percentage(percent)
295 | <> ")",
296 | ),
297 | ])
298 | }
299 |
300 | fn info_item(label, value) {
301 | html.div([attribute.class("info-item")], [
302 | html.div([attribute.class("info-label")], [html.text(label)]),
303 | html.div([attribute.class("info-value")], [html.text(value)]),
304 | ])
305 | }
306 |
307 | fn info_field(label, default_value, handler) {
308 | html.div([attribute.class("info-item")], [
309 | html.label([attribute.class("info-label")], [html.text(label)]),
310 | html.input([
311 | event.on_input(handler),
312 | attribute.class("info-value"),
313 | attribute.placeholder(default_value),
314 | ]),
315 | ])
316 | }
317 |
318 | fn target_actions(model: Model) {
319 | html.div([attribute.class("info-item")], [
320 | html.a([attribute.class("button"), attribute.href("/dashboard")], [
321 | html.text("Reset to Self"),
322 | ]),
323 | html.a(
324 | [
325 | attribute.class("button"),
326 | attribute.href(
327 | "/dashboard"
328 | <> []
329 | |> common.add_param("node", model.node_input)
330 | |> common.add_param("cookie", model.cookie_input)
331 | |> common.add_param("refresh", model.refresh_input)
332 | |> common.encode_params(),
333 | ),
334 | ],
335 | [html.text("Inspect Target")],
336 | ),
337 | ])
338 | }
339 |
340 | fn split_section(left, right) {
341 | html.div([attribute.class("split")], [
342 | html.div([attribute.class("split-left")], left),
343 | html.div([attribute.class("split-right")], right),
344 | ])
345 | }
346 |
347 | fn render_memory_section(
348 | memory_stats: api.MemoryStatistics,
349 | memory_relative: RelativeMemoryStatistics,
350 | ) {
351 | html.section([], [
352 | html.h1([], [html.text("Memory Usage")]),
353 | charts.column_chart([
354 | ChartSegment(
355 | common.colour_process,
356 | "Processes",
357 | memory_relative.processes,
358 | ),
359 | ChartSegment(common.colour_code, "Code ", memory_relative.code),
360 | ChartSegment(common.colour_ets, "ETS", memory_relative.ets),
361 | ChartSegment(common.colour_atom, "Atoms", memory_relative.atom),
362 | ChartSegment(common.colour_binary, "Binaries", memory_relative.binary),
363 | ChartSegment(common.colour_other, "Other", memory_relative.other),
364 | ]),
365 | html.div([attribute.class("memory-breakdown")], [
366 | charts.legend_item(
367 | "Total",
368 | "var(--background)",
369 | display.storage(memory_stats.total),
370 | ),
371 | charts.legend_item(
372 | "Processes",
373 | common.colour_process,
374 | display.storage(memory_stats.processes),
375 | ),
376 | charts.legend_item(
377 | "Code",
378 | common.colour_code,
379 | display.storage(memory_stats.code),
380 | ),
381 | charts.legend_item(
382 | "ETS",
383 | common.colour_ets,
384 | display.storage(memory_stats.ets),
385 | ),
386 | charts.legend_item(
387 | "Atoms",
388 | common.colour_atom,
389 | display.storage(memory_stats.atom),
390 | ),
391 | charts.legend_item(
392 | "Binaries",
393 | common.colour_binary,
394 | display.storage(memory_stats.binary),
395 | ),
396 | charts.legend_item(
397 | "Other",
398 | common.colour_other,
399 | display.storage(memory_relative.other_absolute),
400 | ),
401 | ]),
402 | ])
403 | }
404 |
--------------------------------------------------------------------------------
/src/spectator/internal/components/ets_overview_live.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/atom
2 | import gleam/erlang/process
3 | import gleam/option.{type Option, None, Some}
4 | import gleam/result
5 | import gleam/uri
6 | import lustre
7 | import lustre/attribute
8 | import lustre/effect
9 | import lustre/element.{type Element}
10 | import lustre/element/html
11 | import spectator/internal/api
12 | import spectator/internal/common
13 | import spectator/internal/views/display
14 | import spectator/internal/views/table
15 |
16 | // MAIN ------------------------------------------------------------------------
17 |
18 | pub fn app() {
19 | lustre.application(init, update, view)
20 | }
21 |
22 | // MODEL -----------------------------------------------------------------------
23 |
24 | pub type Model {
25 | Model(
26 | node: api.ErlangNode,
27 | params: common.Params,
28 | subject: Option(process.Subject(Msg)),
29 | refresh_interval: Int,
30 | tables: List(api.Table),
31 | word_size: Int,
32 | sort_criteria: api.TableSortCriteria,
33 | sort_direction: api.SortDirection,
34 | )
35 | }
36 |
37 | fn init(params) {
38 | let node = api.node_from_params(params)
39 | let defaul_sort_criteria = api.SortByTableSize
40 | let defaul_sort_direction = api.Descending
41 | let refresh_interval = common.get_refresh_interval(params)
42 | let word_size = result.unwrap(api.get_word_size(node), 8)
43 | #(
44 | Model(
45 | node:,
46 | params: common.sanitize_params(params),
47 | subject: None,
48 | refresh_interval:,
49 | tables: get_sorted_tables(
50 | node,
51 | defaul_sort_criteria,
52 | defaul_sort_direction,
53 | ),
54 | word_size:,
55 | sort_criteria: defaul_sort_criteria,
56 | sort_direction: defaul_sort_direction,
57 | ),
58 | common.emit_after(refresh_interval, Refresh, None, CreatedSubject),
59 | )
60 | }
61 |
62 | // UPDATE ----------------------------------------------------------------------
63 |
64 | pub opaque type Msg {
65 | Refresh
66 | HeadingClicked(api.TableSortCriteria)
67 | CreatedSubject(process.Subject(Msg))
68 | }
69 |
70 | fn get_sorted_tables(
71 | node: api.ErlangNode,
72 | sort_criteria,
73 | sort_direction,
74 | ) -> List(api.Table) {
75 | result.unwrap(api.list_ets_tables(node), [])
76 | |> api.sort_table_list(sort_criteria, sort_direction)
77 | }
78 |
79 | fn update(model: Model, msg: Msg) {
80 | case msg {
81 | Refresh -> #(
82 | Model(
83 | ..model,
84 | tables: get_sorted_tables(
85 | model.node,
86 | model.sort_criteria,
87 | model.sort_direction,
88 | ),
89 | ),
90 | common.emit_after(
91 | model.refresh_interval,
92 | Refresh,
93 | model.subject,
94 | CreatedSubject,
95 | ),
96 | )
97 | CreatedSubject(subject) -> #(
98 | Model(..model, subject: Some(subject)),
99 | effect.none(),
100 | )
101 | HeadingClicked(criteria) -> {
102 | case criteria {
103 | c if c == model.sort_criteria -> {
104 | let new_direction = api.invert_sort_direction(model.sort_direction)
105 | let new_model =
106 | Model(
107 | ..model,
108 | tables: get_sorted_tables(model.node, c, new_direction),
109 | sort_direction: new_direction,
110 | )
111 | #(new_model, effect.none())
112 | }
113 | c -> {
114 | let new_model =
115 | Model(
116 | ..model,
117 | tables: get_sorted_tables(model.node, c, model.sort_direction),
118 | sort_criteria: c,
119 | )
120 | #(new_model, effect.none())
121 | }
122 | }
123 | }
124 | }
125 | }
126 |
127 | // VIEW ------------------------------------------------------------------------
128 |
129 | fn view(model: Model) -> Element(Msg) {
130 | html.table([], [
131 | html.thead([], [
132 | html.tr([], [
133 | table.heading(
134 | "Name",
135 | "Table Name",
136 | api.SortByTableName,
137 | model.sort_criteria,
138 | model.sort_direction,
139 | HeadingClicked,
140 | False,
141 | ),
142 | table.heading(
143 | "Type",
144 | "Table Type",
145 | api.SortByTableType,
146 | model.sort_criteria,
147 | model.sort_direction,
148 | HeadingClicked,
149 | False,
150 | ),
151 | table.heading(
152 | "Size",
153 | "Table Size",
154 | api.SortByTableSize,
155 | model.sort_criteria,
156 | model.sort_direction,
157 | HeadingClicked,
158 | True,
159 | ),
160 | table.heading(
161 | "Memory",
162 | "Table Memory",
163 | api.SortByTableMemory,
164 | model.sort_criteria,
165 | model.sort_direction,
166 | HeadingClicked,
167 | True,
168 | ),
169 | table.heading(
170 | "Owner",
171 | "Table Owner",
172 | api.SortByTableOwner,
173 | model.sort_criteria,
174 | model.sort_direction,
175 | HeadingClicked,
176 | True,
177 | ),
178 | table.heading(
179 | "Protection",
180 | "Table Protection",
181 | api.SortByTableProtection,
182 | model.sort_criteria,
183 | model.sort_direction,
184 | HeadingClicked,
185 | True,
186 | ),
187 | table.heading(
188 | "Read Conc.",
189 | "Table Read Concurrency",
190 | api.SortByTableReadConcurrency,
191 | model.sort_criteria,
192 | model.sort_direction,
193 | HeadingClicked,
194 | True,
195 | ),
196 | table.heading(
197 | "Write Conc.",
198 | "Table Write Concurrency",
199 | api.SortByTableWriteConcurrency,
200 | model.sort_criteria,
201 | model.sort_direction,
202 | HeadingClicked,
203 | True,
204 | ),
205 | ]),
206 | ]),
207 | html.tbody(
208 | [],
209 | table.map_rows(model.tables, fn(t) {
210 | html.tr([], [
211 | html.td(
212 | [attribute.class("link-cell")],
213 | link_cell(t, [display.atom(t.name)], model.params),
214 | ),
215 | html.td(
216 | [attribute.class("link-cell")],
217 | link_cell(t, [display.atom(t.table_type)], model.params),
218 | ),
219 | html.td(
220 | [attribute.class("cell-right link-cell")],
221 | link_cell(
222 | t,
223 | [display.number(t.size), html.text(" items")],
224 | model.params,
225 | ),
226 | ),
227 | html.td(
228 | [attribute.class("cell-right link-cell")],
229 | link_cell(
230 | t,
231 | [display.storage_words(t.memory, model.word_size)],
232 | model.params,
233 | ),
234 | ),
235 | html.td([attribute.class("cell-right link-cell")], [
236 | display.system_primitive(t.owner, model.params),
237 | ]),
238 | html.td(
239 | [attribute.class("cell-right link-cell")],
240 | link_cell(t, [display.atom(t.protection)], model.params),
241 | ),
242 | html.td(
243 | [attribute.class("cell-right link-cell")],
244 | link_cell(t, [display.bool(t.read_concurrency)], model.params),
245 | ),
246 | html.td(
247 | [attribute.class("cell-right link-cell")],
248 | link_cell(t, [display.bool(t.write_concurrency)], model.params),
249 | ),
250 | ])
251 | }),
252 | ),
253 | html.tfoot([], [
254 | html.tr([], [
255 | html.td([attribute.attribute("colspan", "8")], [
256 | html.div([attribute.class("footer-placeholder")], [
257 | html.text("Click on a table to view data"),
258 | ]),
259 | ]),
260 | ]),
261 | ]),
262 | ])
263 | }
264 |
265 | fn link_cell(
266 | t: api.Table,
267 | children: List(element.Element(Msg)),
268 | params: common.Params,
269 | ) -> List(element.Element(Msg)) {
270 | [
271 | html.a(
272 | [
273 | attribute.href(
274 | "/ets/"
275 | <> uri.percent_encode(atom.to_string(t.name))
276 | <> common.encode_params(params),
277 | ),
278 | ],
279 | children,
280 | ),
281 | ]
282 | }
283 |
--------------------------------------------------------------------------------
/src/spectator/internal/components/ets_table_live.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/atom
2 | import gleam/erlang/process
3 | import gleam/int
4 | import gleam/list
5 | import gleam/option.{type Option, None, Some}
6 | import gleam/result
7 | import lustre
8 | import lustre/attribute
9 | import lustre/effect
10 | import lustre/element.{type Element}
11 | import lustre/element/html
12 | import spectator/internal/api
13 | import spectator/internal/common
14 | import spectator/internal/views/display
15 | import spectator/internal/views/table
16 |
17 | // MAIN ------------------------------------------------------------------------
18 |
19 | pub fn app() {
20 | lustre.application(init, update, view)
21 | }
22 |
23 | // MODEL -----------------------------------------------------------------------
24 |
25 | pub type Model {
26 | Model(
27 | node: api.ErlangNode,
28 | params: common.Params,
29 | subject: Option(process.Subject(Msg)),
30 | refresh_interval: Int,
31 | table: Option(api.Table),
32 | table_data: Option(api.TableData),
33 | sort_column: Option(Int),
34 | sort_direction: api.SortDirection,
35 | )
36 | }
37 |
38 | /// Sometimes we can't get the table info from an atom directly
39 | /// so we fall back to the slower list lookup here
40 | fn get_ets_table_info_from_list(node: api.ErlangNode, table_name: atom.Atom) {
41 | use tables <- result.try(api.list_ets_tables(node))
42 | list.find(tables, fn(t) { t.name == table_name })
43 | |> result.replace_error(api.ReturnedUndefinedError)
44 | }
45 |
46 | fn get_initial_data(params: common.Params) -> Result(Model, api.ErlangError) {
47 | let node = api.node_from_params(params)
48 | use table_name <- result.try(
49 | common.get_param(params, "table_name")
50 | |> result.replace_error(api.ReturnedUndefinedError),
51 | )
52 | use table_atom <- result.try(
53 | atom.from_string(table_name)
54 | |> result.replace_error(api.ReturnedUndefinedError),
55 | )
56 | use table <- result.try(
57 | result.lazy_or(api.get_ets_table_info(node, table_atom), fn() {
58 | get_ets_table_info_from_list(node, table_atom)
59 | }),
60 | )
61 | let refresh_interval = common.get_refresh_interval(params)
62 | let table_data =
63 | api.get_ets_data(node, table)
64 | |> option.from_result
65 | Ok(Model(
66 | node:,
67 | params: common.sanitize_params(params),
68 | subject: None,
69 | refresh_interval:,
70 | table: Some(table),
71 | table_data: table_data,
72 | sort_column: None,
73 | sort_direction: api.Descending,
74 | ))
75 | }
76 |
77 | fn init(params: common.Params) {
78 | case get_initial_data(params) {
79 | Ok(model) -> #(
80 | model,
81 | common.emit_after(model.refresh_interval, Refresh, None, CreatedSubject),
82 | )
83 | Error(_) -> #(
84 | Model(
85 | None,
86 | [],
87 | None,
88 | common.refresh_interval_default,
89 | None,
90 | None,
91 | None,
92 | api.Descending,
93 | ),
94 | effect.none(),
95 | )
96 | }
97 | }
98 |
99 | // UPDATE ----------------------------------------------------------------------
100 |
101 | pub opaque type Msg {
102 | Refresh
103 | CreatedSubject(process.Subject(Msg))
104 | HeadingClicked(Option(Int))
105 | }
106 |
107 | fn do_refresh(model: Model) -> Model {
108 | case model.table {
109 | Some(t) -> {
110 | case api.get_ets_data(model.node, t), model.sort_column {
111 | Ok(data), Some(sort_column_index) -> {
112 | // see it, say it,
113 | let sorted =
114 | api.sort_table_data(data, sort_column_index, model.sort_direction)
115 |
116 | Model(..model, table_data: Some(sorted))
117 | }
118 | Ok(data), None -> Model(..model, table_data: Some(data))
119 | Error(_), _ ->
120 | Model(
121 | None,
122 | [],
123 | None,
124 | common.refresh_interval_default,
125 | None,
126 | None,
127 | None,
128 | api.Descending,
129 | )
130 | }
131 | }
132 | _ -> model
133 | }
134 | }
135 |
136 | fn update(model: Model, msg: Msg) {
137 | case msg {
138 | Refresh -> #(
139 | do_refresh(model),
140 | common.emit_after(
141 | model.refresh_interval,
142 | Refresh,
143 | model.subject,
144 | CreatedSubject,
145 | ),
146 | )
147 |
148 | CreatedSubject(subject) -> #(
149 | Model(..model, subject: Some(subject)),
150 | effect.none(),
151 | )
152 | HeadingClicked(column) -> {
153 | case column {
154 | _c if column == model.sort_column -> {
155 | #(
156 | Model(
157 | ..model,
158 | sort_direction: api.invert_sort_direction(model.sort_direction),
159 | )
160 | |> do_refresh,
161 | effect.none(),
162 | )
163 | }
164 | c -> {
165 | #(Model(..model, sort_column: c) |> do_refresh, effect.none())
166 | }
167 | }
168 | }
169 | }
170 | }
171 |
172 | // VIEW ------------------------------------------------------------------------
173 |
174 | fn view(model: Model) -> Element(Msg) {
175 | case model.table, model.table_data {
176 | Some(table), Some(data) -> render_table_data(model, table, data)
177 | _, _ -> render_not_found()
178 | }
179 | }
180 |
181 | fn render_table_data(model: Model, table: api.Table, data: api.TableData) {
182 | case data.max_length == 0 {
183 | True ->
184 | html.div([attribute.class("component-error")], [
185 | html.text("No data in table "),
186 | display.atom(table.name),
187 | ])
188 | False ->
189 | html.table([attribute.class("ets-data")], [
190 | html.thead([], [
191 | html.tr(
192 | [],
193 | list.range(0, data.max_length - 1)
194 | |> list.map(fn(i) {
195 | table.heading(
196 | int.to_string(i),
197 | int.to_string(i),
198 | Some(i),
199 | model.sort_column,
200 | model.sort_direction,
201 | HeadingClicked,
202 | False,
203 | )
204 | }),
205 | ),
206 | ]),
207 | html.tbody(
208 | [],
209 | table.map_rows(data.content, fn(row) {
210 | html.tr(
211 | [attribute.class("ets-row")],
212 | list.map(row, fn(cell) { html.td([], [display.inspect(cell)]) }),
213 | )
214 | }),
215 | ),
216 | ])
217 | }
218 | }
219 |
220 | fn render_not_found() {
221 | html.div([attribute.class("component-error")], [
222 | html.text("The referenced table could not be loaded."),
223 | ])
224 | }
225 |
--------------------------------------------------------------------------------
/src/spectator/internal/components/ports_live.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/atom
2 | import gleam/erlang/process
3 | import gleam/list
4 | import gleam/option.{type Option, None, Some}
5 | import lustre
6 | import lustre/attribute
7 | import lustre/effect
8 | import lustre/element.{type Element}
9 | import lustre/element/html
10 | import lustre/event
11 | import spectator/internal/api
12 | import spectator/internal/common
13 | import spectator/internal/views/display
14 | import spectator/internal/views/table
15 |
16 | // MAIN ------------------------------------------------------------------------
17 |
18 | pub fn app() {
19 | lustre.application(init, update, view)
20 | }
21 |
22 | // MODEL -----------------------------------------------------------------------
23 |
24 | pub type Model {
25 | Model(
26 | node: api.ErlangNode,
27 | params: common.Params,
28 | subject: Option(process.Subject(Msg)),
29 | refresh_interval: Int,
30 | port_list: List(api.PortItem),
31 | sort_criteria: api.PortSortCriteria,
32 | sort_direction: api.SortDirection,
33 | active_port: Option(api.PortItem),
34 | details: Option(api.PortDetails),
35 | )
36 | }
37 |
38 | fn init(params: common.Params) -> #(Model, effect.Effect(Msg)) {
39 | let node = api.node_from_params(params)
40 | let info = api.get_port_list(node)
41 | let refresh_interval = common.get_refresh_interval(params)
42 | let default_sort_criteria = api.SortByPortInput
43 | let default_sort_direction = api.Descending
44 | let sorted = api.sort_port_list(info, default_sort_criteria, api.Descending)
45 | let active_port = case common.get_param(params, "selected") {
46 | Error(_) -> None
47 | Ok(raw_port_id) -> {
48 | use port_id <- option.then(
49 | api.decode_port(raw_port_id) |> option.from_result,
50 | )
51 | use info <- option.then(
52 | api.get_port_info(node, port_id)
53 | |> option.from_result,
54 | )
55 | Some(api.PortItem(port_id:, info:))
56 | }
57 | }
58 | let details = case active_port {
59 | None -> None
60 | Some(ap) -> {
61 | case api.get_port_details(node, ap.port_id) {
62 | Ok(details) -> Some(details)
63 | Error(_) -> None
64 | }
65 | }
66 | }
67 | #(
68 | Model(
69 | node:,
70 | params: common.sanitize_params(params),
71 | subject: option.None,
72 | refresh_interval: refresh_interval,
73 | port_list: sorted,
74 | sort_criteria: default_sort_criteria,
75 | sort_direction: default_sort_direction,
76 | active_port:,
77 | details:,
78 | ),
79 | common.emit_after(refresh_interval, Refresh, option.None, CreatedSubject),
80 | )
81 | }
82 |
83 | // UPDATE ----------------------------------------------------------------------
84 |
85 | pub opaque type Msg {
86 | Refresh
87 | CreatedSubject(process.Subject(Msg))
88 | PortClicked(api.PortItem)
89 | HeadingClicked(api.PortSortCriteria)
90 | }
91 |
92 | fn do_refresh(model: Model) -> Model {
93 | let info = api.get_port_list(model.node)
94 | let sorted =
95 | api.sort_port_list(info, model.sort_criteria, model.sort_direction)
96 |
97 | let active_port = case model.active_port {
98 | None -> None
99 | Some(active_port) -> {
100 | case api.get_port_info(model.node, active_port.port_id) {
101 | Ok(info) -> Some(api.PortItem(active_port.port_id, info))
102 | Error(_) -> None
103 | }
104 | }
105 | }
106 |
107 | let details = case active_port {
108 | None -> None
109 | Some(ap) -> {
110 | case api.get_port_details(model.node, ap.port_id) {
111 | Ok(details) -> {
112 | Some(details)
113 | }
114 | Error(_) -> None
115 | }
116 | }
117 | }
118 | Model(..model, active_port:, port_list: sorted, details:)
119 | }
120 |
121 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
122 | case msg {
123 | Refresh -> {
124 | #(
125 | do_refresh(model),
126 | common.emit_after(
127 | model.refresh_interval,
128 | Refresh,
129 | model.subject,
130 | CreatedSubject,
131 | ),
132 | )
133 | }
134 | CreatedSubject(subject) -> #(
135 | Model(..model, subject: Some(subject)),
136 | effect.none(),
137 | )
138 | PortClicked(p) -> {
139 | let new_model =
140 | Model(..model, active_port: Some(p))
141 | |> do_refresh
142 | #(new_model, effect.none())
143 | }
144 | HeadingClicked(criteria) -> {
145 | case criteria {
146 | c if c == model.sort_criteria -> {
147 | let new_model =
148 | Model(
149 | ..model,
150 | sort_direction: api.invert_sort_direction(model.sort_direction),
151 | )
152 | #(do_refresh(new_model), effect.none())
153 | }
154 | c -> {
155 | let new_model = Model(..model, sort_criteria: c)
156 | #(do_refresh(new_model), effect.none())
157 | }
158 | }
159 | }
160 | }
161 | }
162 |
163 | // VIEW ------------------------------------------------------------------------
164 |
165 | fn view(model: Model) -> Element(Msg) {
166 | html.table([], [
167 | html.thead([], [
168 | html.tr([], [
169 | table.heading(
170 | "Name",
171 | "Port ID or registered name",
172 | api.SortByPortName,
173 | model.sort_criteria,
174 | model.sort_direction,
175 | HeadingClicked,
176 | align_right: False,
177 | ),
178 | table.heading(
179 | "Command",
180 | "Command name that started the port",
181 | api.SortByPortCommand,
182 | model.sort_criteria,
183 | model.sort_direction,
184 | HeadingClicked,
185 | align_right: False,
186 | ),
187 | table.heading(
188 | "Process",
189 | "Process connected to the port",
190 | api.SortByPortConnectedProcess,
191 | model.sort_criteria,
192 | model.sort_direction,
193 | HeadingClicked,
194 | align_right: False,
195 | ),
196 | table.heading(
197 | "OS PID",
198 | "Operating system process ID",
199 | api.SortByPortOsPid,
200 | model.sort_criteria,
201 | model.sort_direction,
202 | HeadingClicked,
203 | align_right: False,
204 | ),
205 | table.heading(
206 | "Input",
207 | "Input: Number of bytes read from the port",
208 | api.SortByPortInput,
209 | model.sort_criteria,
210 | model.sort_direction,
211 | HeadingClicked,
212 | align_right: True,
213 | ),
214 | table.heading(
215 | "Output",
216 | "Output: Number of bytes written to the port",
217 | api.SortByPortOutput,
218 | model.sort_criteria,
219 | model.sort_direction,
220 | HeadingClicked,
221 | align_right: True,
222 | ),
223 | table.heading(
224 | "Memory",
225 | "Memory allocated for this port by the runtime system",
226 | api.SortByPortMemory,
227 | model.sort_criteria,
228 | model.sort_direction,
229 | HeadingClicked,
230 | align_right: True,
231 | ),
232 | table.heading(
233 | "Queue",
234 | "Queue Size",
235 | api.SortByPortQueueSize,
236 | model.sort_criteria,
237 | model.sort_direction,
238 | HeadingClicked,
239 | align_right: True,
240 | ),
241 | ]),
242 | ]),
243 | html.tbody(
244 | [],
245 | table.map_rows(model.port_list, fn(port) {
246 | html.tr(
247 | [
248 | attribute.role("button"),
249 | case model.active_port {
250 | Some(active) if active.port_id == port.port_id ->
251 | attribute.class("selected")
252 | _ -> attribute.none()
253 | },
254 | event.on_click(PortClicked(port)),
255 | ],
256 | [
257 | html.td([], [render_name(port)]),
258 | html.td([], [html.text(port.info.command_name)]),
259 | html.td([attribute.class("link-cell")], [
260 | display.system_primitive(
261 | port.info.connected_process,
262 | model.params,
263 | ),
264 | ]),
265 | html.td([], [
266 | case port.info.os_pid {
267 | Some(pid) -> display.number(pid)
268 | None -> html.text("N/A")
269 | },
270 | ]),
271 | html.td([attribute.class("cell-right")], [
272 | display.storage(port.info.input),
273 | ]),
274 | html.td([attribute.class("cell-right")], [
275 | display.storage(port.info.output),
276 | ]),
277 | html.td([attribute.class("cell-right")], [
278 | display.storage(port.info.memory),
279 | ]),
280 | html.td([attribute.class("cell-right")], [
281 | display.number(port.info.queue_size),
282 | ]),
283 | ],
284 | )
285 | }),
286 | ),
287 | html.tfoot([], [
288 | html.tr([], [
289 | html.td([attribute.attribute("colspan", "8")], [
290 | render_details(model.active_port, model.details, model.params),
291 | ]),
292 | ]),
293 | ]),
294 | ])
295 | }
296 |
297 | fn render_name(process: api.PortItem) {
298 | case process.info.registered_name {
299 | option.None -> display.port(process.port_id)
300 | option.Some(name) -> html.text(atom.to_string(name))
301 | }
302 | }
303 |
304 | fn render_primitive_list(
305 | primitives: List(api.SystemPrimitive),
306 | params: common.Params,
307 | ) {
308 | list.map(primitives, display.system_primitive(_, params))
309 | |> list.intersperse(html.text(", "))
310 | }
311 |
312 | fn render_details(
313 | p: Option(api.PortItem),
314 | d: Option(api.PortDetails),
315 | params: common.Params,
316 | ) {
317 | case p, d {
318 | Some(port), Some(details) ->
319 | html.div([attribute.class("details compact")], [
320 | html.dl([], [
321 | html.dt([], [html.text("Connected Process")]),
322 | html.dd([], [
323 | display.system_primitive(port.info.connected_process, params),
324 | ]),
325 | ]),
326 | html.dl([], [
327 | html.dt([], [html.text("Links")]),
328 | html.dd([], render_primitive_list(details.links, params)),
329 | ]),
330 | html.dl([], [
331 | html.dt([], [html.text("Monitored By")]),
332 | html.dd([], render_primitive_list(details.monitored_by, params)),
333 | ]),
334 | html.dl([], [
335 | html.dt([], [html.text("Monitors")]),
336 | html.dd([], render_primitive_list(details.monitors, params)),
337 | ]),
338 | ])
339 | _, _ ->
340 | html.div([attribute.class("footer-placeholder")], [
341 | html.text("Click a process to see details"),
342 | ])
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/src/spectator/internal/components/processes_live.gleam:
--------------------------------------------------------------------------------
1 | import gleam/dynamic
2 | import gleam/erlang/atom
3 | import gleam/erlang/process
4 | import gleam/list
5 | import gleam/option.{type Option, None, Some}
6 | import lustre
7 | import lustre/attribute
8 | import lustre/effect
9 | import lustre/element.{type Element}
10 | import lustre/element/html
11 | import lustre/event
12 | import pprint
13 | import spectator/internal/api
14 | import spectator/internal/common
15 | import spectator/internal/views/display
16 | import spectator/internal/views/table
17 |
18 | // MAIN ------------------------------------------------------------------------
19 |
20 | pub fn app() {
21 | lustre.application(init, update, view)
22 | }
23 |
24 | // MODEL -----------------------------------------------------------------------
25 |
26 | pub type Model {
27 | Model(
28 | node: api.ErlangNode,
29 | params: common.Params,
30 | subject: Option(process.Subject(Msg)),
31 | refresh_interval: Int,
32 | process_list: List(api.ProcessItem),
33 | sort_criteria: api.ProcessSortCriteria,
34 | sort_direction: api.SortDirection,
35 | active_process: Option(api.ProcessItem),
36 | details: Option(api.ProcessDetails),
37 | status: Option(api.ProcessOtpStatus),
38 | state: Option(dynamic.Dynamic),
39 | )
40 | }
41 |
42 | fn emit_message(msg: Msg) -> effect.Effect(Msg) {
43 | effect.from(fn(dispatch) { dispatch(msg) })
44 | }
45 |
46 | fn request_otp_details(
47 | node: api.ErlangNode,
48 | pid: process.Pid,
49 | subject: Option(process.Subject(Msg)),
50 | ) -> effect.Effect(Msg) {
51 | case subject {
52 | Some(sub) -> {
53 | use _ <- effect.from
54 | api.request_otp_data(node, pid, fn(details) {
55 | process.send(sub, ReceivedOtpDetails(details))
56 | })
57 | Nil
58 | }
59 | None -> effect.none()
60 | }
61 | }
62 |
63 | fn init(params: common.Params) -> #(Model, effect.Effect(Msg)) {
64 | let node = api.node_from_params(params)
65 | let info = api.get_process_list(node)
66 | let default_sort_criteria = api.SortByReductions
67 | let default_sort_direction = api.Descending
68 | let refresh_interval = common.get_refresh_interval(params)
69 | let sorted =
70 | api.sort_process_list(info, default_sort_criteria, api.Descending)
71 | #(
72 | Model(
73 | node:,
74 | params: common.sanitize_params(params),
75 | subject: option.None,
76 | refresh_interval:,
77 | process_list: sorted,
78 | sort_criteria: default_sort_criteria,
79 | sort_direction: default_sort_direction,
80 | active_process: option.None,
81 | details: option.None,
82 | status: option.None,
83 | state: option.None,
84 | ),
85 | effect.batch([
86 | common.emit_after(refresh_interval, Refresh, option.None, CreatedSubject),
87 | case common.get_param(params, "selected") {
88 | Error(_) -> effect.none()
89 | Ok(potential_pid) -> {
90 | case api.decode_pid(potential_pid) {
91 | Ok(pid) -> emit_message(PidClicked(pid))
92 | Error(_) -> effect.none()
93 | }
94 | }
95 | },
96 | ]),
97 | )
98 | }
99 |
100 | // UPDATE ----------------------------------------------------------------------
101 |
102 | pub opaque type Msg {
103 | Refresh
104 | ReceivedOtpDetails(details: api.OtpDetails)
105 | CreatedSubject(process.Subject(Msg))
106 | ProcessClicked(api.ProcessItem)
107 | HeadingClicked(api.ProcessSortCriteria)
108 | OtpStateClicked(api.ProcessItem, api.SysState)
109 | PidClicked(process.Pid)
110 | KillClicked(api.ProcessItem)
111 | }
112 |
113 | fn do_refresh(model: Model) -> Model {
114 | let info = api.get_process_list(model.node)
115 | let sorted =
116 | api.sort_process_list(info, model.sort_criteria, model.sort_direction)
117 |
118 | let active_process = case model.active_process {
119 | None -> None
120 | Some(active_process) -> {
121 | case api.get_process_info(model.node, active_process.pid) {
122 | Ok(info) -> Some(api.ProcessItem(active_process.pid, info))
123 | Error(_) -> None
124 | }
125 | }
126 | }
127 |
128 | let details = case active_process {
129 | None -> None
130 | Some(ap) -> {
131 | case api.get_details(model.node, ap.pid) {
132 | Ok(details) -> {
133 | Some(details)
134 | }
135 | Error(_) -> None
136 | }
137 | }
138 | }
139 | Model(..model, active_process:, process_list: sorted, details:)
140 | }
141 |
142 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
143 | case msg {
144 | Refresh -> {
145 | let new_model = do_refresh(model)
146 | case new_model.active_process {
147 | Some(p) if p.info.message_queue_len < common.message_queue_threshold -> #(
148 | new_model,
149 | effect.batch([
150 | request_otp_details(model.node, p.pid, model.subject),
151 | common.emit_after(
152 | model.refresh_interval,
153 | Refresh,
154 | model.subject,
155 | CreatedSubject,
156 | ),
157 | ]),
158 | )
159 | _ -> #(
160 | new_model,
161 | common.emit_after(
162 | model.refresh_interval,
163 | Refresh,
164 | model.subject,
165 | CreatedSubject,
166 | ),
167 | )
168 | }
169 | }
170 | CreatedSubject(subject) -> #(
171 | Model(..model, subject: Some(subject)),
172 | effect.none(),
173 | )
174 | ProcessClicked(p) -> {
175 | let new_model =
176 | Model(..model, active_process: Some(p), state: None, status: None)
177 | |> do_refresh
178 | #(new_model, request_otp_details(model.node, p.pid, model.subject))
179 | }
180 | HeadingClicked(criteria) -> {
181 | case criteria {
182 | c if c == model.sort_criteria -> {
183 | let new_model =
184 | Model(
185 | ..model,
186 | sort_direction: api.invert_sort_direction(model.sort_direction),
187 | )
188 | #(do_refresh(new_model), effect.none())
189 | }
190 | c -> {
191 | let new_model = Model(..model, sort_criteria: c)
192 | #(do_refresh(new_model), effect.none())
193 | }
194 | }
195 | }
196 | ReceivedOtpDetails(details) -> {
197 | case model.active_process {
198 | Some(p) if p.pid == details.pid -> {
199 | #(
200 | Model(
201 | ..model,
202 | state: Some(details.state),
203 | status: Some(details.status),
204 | ),
205 | effect.none(),
206 | )
207 | }
208 | _ -> #(model, effect.none())
209 | }
210 | }
211 | OtpStateClicked(p, target_sys_state) -> {
212 | case target_sys_state {
213 | api.ProcessSuspended -> {
214 | let _ = api.resume(model.node, p.pid)
215 | #(
216 | do_refresh(model),
217 | request_otp_details(model.node, p.pid, model.subject),
218 | )
219 | }
220 | api.ProcessRunning -> {
221 | let _ = api.suspend(model.node, p.pid)
222 | #(
223 | do_refresh(model),
224 | request_otp_details(model.node, p.pid, model.subject),
225 | )
226 | }
227 | }
228 | }
229 | PidClicked(pid) -> {
230 | case api.get_process_info(model.node, pid) {
231 | Ok(info) -> {
232 | let p = api.ProcessItem(pid, info)
233 | let new_model =
234 | Model(..model, active_process: Some(p), state: None, status: None)
235 | |> do_refresh
236 | #(new_model, request_otp_details(model.node, p.pid, model.subject))
237 | }
238 | Error(_) -> #(model, effect.none())
239 | }
240 | }
241 | KillClicked(p) -> {
242 | let _ = api.kill_process(model.node, p.pid)
243 | #(do_refresh(model), effect.none())
244 | }
245 | }
246 | }
247 |
248 | // VIEW ------------------------------------------------------------------------
249 |
250 | fn view(model: Model) -> Element(Msg) {
251 | html.table([], [
252 | html.thead([], [
253 | html.tr([], [
254 | table.heading(
255 | "Name",
256 | "Process PID or registered name",
257 | api.SortByProcessName,
258 | model.sort_criteria,
259 | model.sort_direction,
260 | HeadingClicked,
261 | align_right: False,
262 | ),
263 | table.heading(
264 | "Tag",
265 | "Spectator tag or initial call",
266 | api.SortByTag,
267 | model.sort_criteria,
268 | model.sort_direction,
269 | HeadingClicked,
270 | align_right: False,
271 | ),
272 | table.heading(
273 | "Current",
274 | "Current function",
275 | api.SortByCurrentFunction,
276 | model.sort_criteria,
277 | model.sort_direction,
278 | HeadingClicked,
279 | align_right: False,
280 | ),
281 | table.heading(
282 | "Reductions",
283 | "Number of reductions",
284 | api.SortByReductions,
285 | model.sort_criteria,
286 | model.sort_direction,
287 | HeadingClicked,
288 | align_right: False,
289 | ),
290 | table.heading(
291 | "Memory",
292 | "Memory usage",
293 | api.SortByProcessMemory,
294 | model.sort_criteria,
295 | model.sort_direction,
296 | HeadingClicked,
297 | align_right: True,
298 | ),
299 | table.heading(
300 | "Msgs",
301 | "Message queue size",
302 | api.SortByMessageQueue,
303 | model.sort_criteria,
304 | model.sort_direction,
305 | HeadingClicked,
306 | align_right: True,
307 | ),
308 | table.heading(
309 | "Status",
310 | "Process Status",
311 | api.SortByProcessStatus,
312 | model.sort_criteria,
313 | model.sort_direction,
314 | HeadingClicked,
315 | align_right: True,
316 | ),
317 | ]),
318 | ]),
319 | html.tbody(
320 | [],
321 | table.map_rows(model.process_list, fn(process) {
322 | html.tr(
323 | [
324 | attribute.role("button"),
325 | classify_selected(process, model.active_process),
326 | event.on_click(ProcessClicked(process)),
327 | ],
328 | [
329 | html.td([], [render_name(process)]),
330 | html.td([], [render_tag(process)]),
331 | html.td([], [display.function(process.info.current_function)]),
332 | html.td([], [display.number(process.info.reductions)]),
333 | html.td([attribute.class("cell-right")], [
334 | display.storage(process.info.memory),
335 | ]),
336 | html.td([attribute.class("cell-right")], [
337 | display.number(process.info.message_queue_len),
338 | ]),
339 | html.td([attribute.class("cell-right")], [
340 | display.atom(process.info.status),
341 | ]),
342 | ],
343 | )
344 | }),
345 | ),
346 | html.tfoot([], [
347 | html.tr([], [
348 | html.td([attribute.attribute("colspan", "7")], [
349 | render_details(
350 | model.active_process,
351 | model.details,
352 | model.status,
353 | model.state,
354 | OtpStateClicked,
355 | model.params,
356 | ),
357 | ]),
358 | ]),
359 | ]),
360 | ])
361 | }
362 |
363 | fn render_name(process: api.ProcessItem) {
364 | case process.info.registered_name {
365 | option.None -> display.pid(process.pid)
366 | option.Some(name) -> html.text(atom.to_string(name))
367 | }
368 | }
369 |
370 | fn render_tag(process: api.ProcessItem) {
371 | case process.info.tag {
372 | option.None -> display.function(process.info.initial_call)
373 | option.Some("__spectator_internal " <> rest) -> html.text("🔍 " <> rest)
374 | option.Some(tag) -> html.text("🔖 " <> tag)
375 | }
376 | }
377 |
378 | fn render_primitive_list(
379 | primitives: List(api.SystemPrimitive),
380 | on_primitive_click: fn(process.Pid) -> Msg,
381 | params: common.Params,
382 | ) {
383 | list.map(primitives, display.system_primitive_interactive(
384 | _,
385 | on_primitive_click,
386 | params,
387 | ))
388 | |> list.intersperse(html.text(", "))
389 | }
390 |
391 | fn render_details(
392 | p: Option(api.ProcessItem),
393 | d: Option(api.ProcessDetails),
394 | status: Option(api.ProcessOtpStatus),
395 | state: Option(dynamic.Dynamic),
396 | handle_otp_state_click: fn(api.ProcessItem, api.SysState) -> Msg,
397 | params: common.Params,
398 | ) {
399 | case p, d {
400 | Some(proc), Some(details) ->
401 | html.div([attribute.class("details")], [
402 | html.div([attribute.class("general")], [
403 | html.div([attribute.class("panel-heading")], [
404 | case proc.info.tag, proc.info.registered_name {
405 | Some("__spectator_internal " <> rest), _ ->
406 | html.text("🔍 Spectator " <> rest)
407 | Some(tag), None ->
408 | html.text("🔖 " <> api.format_pid(proc.pid) <> " " <> tag)
409 | Some(tag), Some(name) ->
410 | html.text(
411 | "🔖 "
412 | <> api.format_pid(proc.pid)
413 | <> " "
414 | <> tag
415 | <> " registered as "
416 | <> atom.to_string(name),
417 | )
418 | None, None ->
419 | html.text("🎛️ " <> api.format_pid(proc.pid) <> " Process")
420 | None, Some(name) ->
421 | html.text(
422 | "📠 "
423 | <> api.format_pid(proc.pid)
424 | <> " "
425 | <> atom.to_string(name),
426 | )
427 | },
428 | html.button(
429 | [
430 | attribute.class("panel-action suspend"),
431 | event.on_click(KillClicked(proc)),
432 | ],
433 | [html.text("🗡️ Kill")],
434 | ),
435 | ]),
436 | html.div([attribute.class("panel-content")], [
437 | html.dl([], [
438 | html.dt([], [html.text("Links")]),
439 | html.dd(
440 | [],
441 | render_primitive_list(details.links, PidClicked, params),
442 | ),
443 | ]),
444 | html.dl([], [
445 | html.dt([], [html.text("Monitored By")]),
446 | html.dd(
447 | [],
448 | render_primitive_list(details.monitored_by, PidClicked, params),
449 | ),
450 | ]),
451 | html.dl([], [
452 | html.dt([], [html.text("Monitors")]),
453 | html.dd(
454 | [],
455 | render_primitive_list(details.monitors, PidClicked, params),
456 | ),
457 | ]),
458 | html.dl([], [
459 | html.dt([], [html.text("Parent")]),
460 | html.dd([], [
461 | case details.parent {
462 | option.None -> html.text("None")
463 | option.Some(parent) ->
464 | display.system_primitive_interactive(
465 | parent,
466 | PidClicked,
467 | params,
468 | )
469 | },
470 | ]),
471 | ]),
472 | html.dl([], [
473 | html.dt([], [html.text("Status")]),
474 | html.dd([], [html.text(atom.to_string(proc.info.status))]),
475 | ]),
476 | html.dl([], [
477 | html.dt([], [html.text("Trap Exit")]),
478 | html.dd([], [display.bool(details.trap_exit)]),
479 | ]),
480 | html.dl([], [
481 | html.dt([], [html.text("Initial Call")]),
482 | html.dd([], [display.function(proc.info.initial_call)]),
483 | ]),
484 | html.dl([], [
485 | html.dt([], [html.text("Current Function")]),
486 | html.dd([], [display.function(proc.info.current_function)]),
487 | ]),
488 | html.dl([], [
489 | html.dt([], [html.text("Reductions")]),
490 | html.dd([], [display.number(proc.info.reductions)]),
491 | ]),
492 | html.dl([], [
493 | html.dt([], [html.text("Memory")]),
494 | html.dd([], [display.storage_detailed(proc.info.memory)]),
495 | ]),
496 | html.dl([], [
497 | html.dt([], [html.text("Message Queue Length")]),
498 | html.dd([], [display.number(proc.info.message_queue_len)]),
499 | ]),
500 | ]),
501 | ]),
502 | html.div([attribute.class("otp")], case status, state {
503 | Some(status), Some(state) -> {
504 | [
505 | html.div([attribute.class("panel-heading")], [
506 | html.strong([], [html.text("☎️ OTP Process: ")]),
507 | display.atom(status.module),
508 | case status.sys_state {
509 | api.ProcessSuspended -> {
510 | html.button(
511 | [
512 | event.on_click(handle_otp_state_click(
513 | proc,
514 | status.sys_state,
515 | )),
516 | attribute.class("panel-action resume"),
517 | ],
518 | [html.text("🏃♀️➡️ Resume")],
519 | )
520 | }
521 | api.ProcessRunning -> {
522 | html.button(
523 | [
524 | event.on_click(handle_otp_state_click(
525 | proc,
526 | status.sys_state,
527 | )),
528 | attribute.class("panel-action suspend"),
529 | ],
530 | [html.text("✋ Suspend")],
531 | )
532 | }
533 | },
534 | ]),
535 | html.div([attribute.class("panel-content")], [
536 | html.pre([], [html.text(pprint.format(state))]),
537 | ]),
538 | ]
539 | }
540 | _, _ if proc.info.message_queue_len >= common.message_queue_threshold -> [
541 | html.div([attribute.class("panel-content")], [
542 | html.strong([], [
543 | html.text(
544 | "⚠️ Spectator has stopped trying to inspect the OTP state of this process",
545 | ),
546 | ]),
547 | html.p([], [
548 | html.text("The message queue length is above the threshold of "),
549 | display.number(common.message_queue_threshold),
550 | html.text("."),
551 | html.br([]),
552 | html.text(
553 | "Spectator will not send it any more system messages to avoid filling the message queue.",
554 | ),
555 | ]),
556 | ]),
557 | ]
558 | _, _ -> [
559 | html.div([attribute.class("panel-content")], [
560 | html.text("This process does not appear to be OTP-compliant"),
561 | ]),
562 | ]
563 | }),
564 | ])
565 | _, _ ->
566 | html.div([attribute.class("footer-placeholder")], [
567 | html.text("Click a process to see details"),
568 | ])
569 | }
570 | }
571 |
572 | fn classify_selected(process: api.ProcessItem, active: Option(api.ProcessItem)) {
573 | let selection_status = case active {
574 | Some(active) if active.pid == process.pid -> "selected"
575 | _ -> ""
576 | }
577 | case process.info.tag {
578 | option.None -> attribute.class(selection_status)
579 | option.Some("__spectator_internal " <> _rest) ->
580 | attribute.class(selection_status <> " spectator-tagged")
581 | option.Some(_) -> attribute.class(selection_status <> " tagged")
582 | }
583 | }
584 |
--------------------------------------------------------------------------------
/src/spectator/internal/views/charts.gleam:
--------------------------------------------------------------------------------
1 | import gleam/float
2 | import gleam/list
3 | import lustre/attribute.{attribute}
4 | import lustre/element.{type Element}
5 | import lustre/element/html
6 | import lustre/element/svg
7 | import spectator/internal/common
8 |
9 | const column_chart_height = "100"
10 |
11 | const meter_height = "25"
12 |
13 | pub type ChartSegment {
14 | ChartSegment(colour: String, label: String, value: Float)
15 | }
16 |
17 | fn map_segments_with_offset(
18 | input: List(ChartSegment),
19 | acc: List(Element(a)),
20 | offset: Float,
21 | ) {
22 | case input {
23 | [] -> list.reverse(acc)
24 | [s, ..rest] -> {
25 | map_segments_with_offset(
26 | rest,
27 | [
28 | // Doing this because svg.rect doesn't allow setting children,
29 | // and needs to be a child of the element
30 | element.namespaced(
31 | "http://www.w3.org/2000/svg",
32 | "rect",
33 | [
34 | attribute.title(
35 | s.label <> " " <> common.format_percentage(s.value),
36 | ),
37 | attribute.style([
38 | #("x", float.to_string(offset) <> "px"),
39 | #("y", "0px"),
40 | #("height", column_chart_height <> "px"),
41 | #("width", float.to_string(s.value) <> "px"),
42 | #("fill", s.colour),
43 | ]),
44 | ],
45 | [
46 | svg.title([], [
47 | html.text(s.label <> " " <> common.format_percentage(s.value)),
48 | ]),
49 | ],
50 | ),
51 | ..acc
52 | ],
53 | offset +. s.value,
54 | )
55 | }
56 | }
57 | }
58 |
59 | /// This column chart assumes that the sum of the values is 100
60 | /// (percentage based data)
61 | pub fn column_chart(segments: List(ChartSegment)) {
62 | html.svg(
63 | [
64 | attribute.class("column-chart"),
65 | attribute("viewBox", "0 0 100 " <> column_chart_height),
66 | attribute("height", column_chart_height <> "px"),
67 | attribute("width", "100%"),
68 | attribute("preserveAspectRatio", "none"),
69 | ],
70 | map_segments_with_offset(segments, [], 0.0),
71 | )
72 | }
73 |
74 | pub fn legend_item(label: String, color: String, value: Element(a)) {
75 | html.div([attribute.class("legend-item")], [
76 | html.div([attribute.class("legend-colour")], [
77 | html.div([attribute.style([#("background-color", color)])], []),
78 | ]),
79 | html.div([], [
80 | html.div([attribute.class("legend-label")], [html.text(label)]),
81 | value,
82 | ]),
83 | ])
84 | }
85 |
86 | /// This meter assumes that the max value is 100
87 | /// (percentage based data)
88 | pub fn meter(value: Float) {
89 | html.svg(
90 | [
91 | attribute.class("meter-chart"),
92 | attribute("viewBox", "0 0 100 " <> meter_height),
93 | attribute("height", meter_height <> "px"),
94 | attribute("width", "100%"),
95 | attribute("preserveAspectRatio", "none"),
96 | ],
97 | [
98 | svg.rect([
99 | attribute.style([
100 | #("x", "0px"),
101 | #("y", "0px"),
102 | #("height", meter_height <> "px"),
103 | #("width", common.truncate_float(value) <> "px"),
104 | #("fill", "var(--meter-bar)"),
105 | ]),
106 | ]),
107 | ],
108 | )
109 | }
110 |
--------------------------------------------------------------------------------
/src/spectator/internal/views/display.gleam:
--------------------------------------------------------------------------------
1 | /// View functions for displaying all kinds of data from the system
2 | import gleam/dynamic
3 | import gleam/erlang
4 | import gleam/erlang/atom
5 | import gleam/erlang/port
6 | import gleam/erlang/process
7 | import gleam/int
8 | import gleam/option.{type Option, None, Some}
9 | import gleam/string
10 | import lustre/attribute
11 | import lustre/element/html
12 | import lustre/event
13 | import spectator/internal/api
14 | import spectator/internal/common
15 |
16 | pub fn pid(pid: process.Pid) {
17 | html.text(api.format_pid(pid))
18 | }
19 |
20 | pub fn pid_button(
21 | pid: process.Pid,
22 | name: Option(atom.Atom),
23 | tag: Option(String),
24 | on_click: fn(process.Pid) -> a,
25 | ) {
26 | case name, tag {
27 | None, None ->
28 | html.button(
29 | [
30 | event.on_click(on_click(pid)),
31 | attribute.class("interactive-primitive"),
32 | ],
33 | [html.text(api.format_pid(pid))],
34 | )
35 | Some(name), None ->
36 | html.button(
37 | [
38 | event.on_click(on_click(pid)),
39 | attribute.class("interactive-primitive named"),
40 | attribute.title("PID" <> api.format_pid(pid)),
41 | ],
42 | [html.text(atom.to_string(name))],
43 | )
44 | None, Some("__spectator_internal" <> internal_tag) ->
45 | html.button(
46 | [
47 | event.on_click(on_click(pid)),
48 | attribute.class("interactive-primitive muted"),
49 | attribute.title("This process is used for the Spectator application"),
50 | ],
51 | [html.text("🔍" <> internal_tag <> api.format_pid(pid))],
52 | )
53 | None, Some(tag) ->
54 | html.button(
55 | [
56 | event.on_click(on_click(pid)),
57 | attribute.class("interactive-primitive tagged"),
58 | ],
59 | [html.text("🔖 " <> tag <> api.format_pid(pid))],
60 | )
61 | Some(name), Some(tag) ->
62 | html.button(
63 | [
64 | event.on_click(on_click(pid)),
65 | attribute.class("interactive-primitive named tagged"),
66 | attribute.title("PID" <> api.format_pid(pid)),
67 | ],
68 | [html.text("🔖 " <> tag <> "<" <> atom.to_string(name) <> ">")],
69 | )
70 | }
71 | }
72 |
73 | pub fn pid_link(
74 | pid: process.Pid,
75 | name: Option(atom.Atom),
76 | tag: Option(String),
77 | params: common.Params,
78 | ) {
79 | case name, tag {
80 | None, None ->
81 | html.a(
82 | [
83 | attribute.href(
84 | "/processes"
85 | <> common.add_param(params, "selected", api.serialize_pid(pid))
86 | |> common.encode_params(),
87 | ),
88 | attribute.class("interactive-primitive"),
89 | ],
90 | [html.text(api.format_pid(pid))],
91 | )
92 | Some(name), None ->
93 | html.a(
94 | [
95 | attribute.href(
96 | "/processes"
97 | <> common.add_param(params, "selected", api.serialize_pid(pid))
98 | |> common.encode_params(),
99 | ),
100 | attribute.class("interactive-primitive named"),
101 | attribute.title("PID" <> api.format_pid(pid)),
102 | ],
103 | [html.text(atom.to_string(name))],
104 | )
105 | None, Some("__spectator_internal" <> internal_tag) ->
106 | html.a(
107 | [
108 | attribute.href(
109 | "/processes"
110 | <> common.add_param(params, "selected", api.serialize_pid(pid))
111 | |> common.encode_params(),
112 | ),
113 | attribute.class("interactive-primitive muted"),
114 | attribute.title("This process is used for the Spectator application"),
115 | ],
116 | [html.text("🔍" <> internal_tag <> api.format_pid(pid))],
117 | )
118 | None, Some(tag) ->
119 | html.a(
120 | [
121 | attribute.href(
122 | "/processes"
123 | <> common.add_param(params, "selected", api.serialize_pid(pid))
124 | |> common.encode_params(),
125 | ),
126 | attribute.class("interactive-primitive tagged"),
127 | ],
128 | [html.text("🔖 " <> tag <> api.format_pid(pid))],
129 | )
130 | Some(name), Some(tag) ->
131 | html.a(
132 | [
133 | attribute.href(
134 | "/processes"
135 | <> common.add_param(params, "selected", api.serialize_pid(pid))
136 | |> common.encode_params(),
137 | ),
138 | attribute.class("interactive-primitive named tagged"),
139 | attribute.title("PID" <> api.format_pid(pid)),
140 | ],
141 | [html.text("🔖 " <> tag <> "<" <> atom.to_string(name) <> ">")],
142 | )
143 | }
144 | }
145 |
146 | pub fn port(port: port.Port) {
147 | html.text(api.format_port(port))
148 | }
149 |
150 | pub fn port_link(
151 | port: port.Port,
152 | name: Option(atom.Atom),
153 | params: common.Params,
154 | ) {
155 | let label = case name {
156 | None -> api.format_port(port)
157 | Some(n) -> api.format_port(port) <> " (" <> atom.to_string(n) <> ")"
158 | }
159 | html.a(
160 | [
161 | attribute.class("interactive-primitive"),
162 | attribute.href(
163 | "/ports"
164 | <> common.add_param(params, "selected", api.serialize_port(port))
165 | |> common.encode_params(),
166 | ),
167 | ],
168 | [html.text(label)],
169 | )
170 | }
171 |
172 | pub fn inspect(d: dynamic.Dynamic) {
173 | html.text(string.inspect(d))
174 | }
175 |
176 | pub fn atom(a: atom.Atom) {
177 | html.text(atom.to_string(a))
178 | }
179 |
180 | pub fn reference(ref: erlang.Reference) {
181 | html.text(string.inspect(ref))
182 | }
183 |
184 | pub fn storage_words(words: Int, word_size: Int) {
185 | storage(words * word_size)
186 | }
187 |
188 | pub fn storage(size: Int) {
189 | case size {
190 | _s if size < 1024 -> html.text(int.to_string(size) <> " B")
191 | _s if size < 1_048_576 -> html.text(int.to_string(size / 1024) <> " KiB")
192 | _s -> html.text(int.to_string(size / 1024 / 1024) <> " MiB")
193 | }
194 | }
195 |
196 | pub fn storage_detailed(size) {
197 | let byte_size = int.to_string(size) <> " Bytes"
198 | case size {
199 | _s if size < 1024 -> html.text(byte_size)
200 | _s if size < 1_048_576 ->
201 | html.text(byte_size <> " (" <> int.to_string(size / 1024) <> " KiB)")
202 | _s ->
203 | html.text(
204 | byte_size <> " (" <> int.to_string(size / 1024 / 1024) <> " MiB)",
205 | )
206 | }
207 | }
208 |
209 | pub fn bool(b: Bool) {
210 | case b {
211 | True -> html.text("true")
212 | False -> html.text("false")
213 | }
214 | }
215 |
216 | pub fn named_remote_process(name: atom.Atom, node: atom.Atom) {
217 | html.text(atom.to_string(name) <> " on " <> atom.to_string(node))
218 | }
219 |
220 | pub fn system_primitive_interactive(
221 | primitive: api.SystemPrimitive,
222 | on_process_click: fn(process.Pid) -> a,
223 | params: common.Params,
224 | ) {
225 | case primitive {
226 | api.ProcessPrimitive(pid:, name:, tag:) ->
227 | pid_button(pid, name, tag, on_process_click)
228 | api.RemoteProcessPrimitive(name:, node:) -> named_remote_process(name, node)
229 | api.PortPrimitive(port_id:, name:) -> port_link(port_id, name, params)
230 | api.NifResourcePrimitive(_) -> html.text("NIF Res.")
231 | }
232 | }
233 |
234 | pub fn system_primitive(primitive: api.SystemPrimitive, params: common.Params) {
235 | case primitive {
236 | api.ProcessPrimitive(pid:, name:, tag:) -> pid_link(pid, name, tag, params)
237 | api.RemoteProcessPrimitive(name:, node:) -> named_remote_process(name, node)
238 | api.PortPrimitive(port_id:, name:) -> port_link(port_id, name, params)
239 | api.NifResourcePrimitive(_) -> html.text("NIF Res.")
240 | }
241 | }
242 |
243 | pub fn function(ref: #(atom.Atom, atom.Atom, Int)) {
244 | let #(module, function, arity) = ref
245 |
246 | html.text(
247 | atom.to_string(module)
248 | <> ":"
249 | <> atom.to_string(function)
250 | <> "/"
251 | <> int.to_string(arity),
252 | )
253 | }
254 |
255 | pub fn number(n: Int) {
256 | html.text(int.to_string(n))
257 | }
258 |
--------------------------------------------------------------------------------
/src/spectator/internal/views/navbar.gleam:
--------------------------------------------------------------------------------
1 | //// View functions for rendering the top navigation bar
2 |
3 | import lustre/attribute.{attribute}
4 | import lustre/element/html
5 | import lustre/element/svg
6 |
7 | fn icon() {
8 | svg.svg(
9 | [
10 | attribute("class", "lucy-icon"),
11 | attribute("xmlns:bx", "https://boxy-svg.com"),
12 | attribute("xmlns", "http://www.w3.org/2000/svg"),
13 | attribute("fill", "none"),
14 | attribute("viewBox", "-40.546 -53.577 2251.673 2524.133"),
15 | ],
16 | [
17 | svg.path([
18 | attribute("transform", "matrix(-1, 0, 0, -1, 0.000033, 0.000056)"),
19 | attribute(
20 | "style",
21 | "transform-box: fill-box; transform-origin: 50% 50%;",
22 | ),
23 | attribute("fill", "#FFAFF3"),
24 | attribute(
25 | "d",
26 | "M 842.763 1885.449 C 870.851 1965.043 975.268 1983.454 1028.887 1918.269 L 1309.907 1576.606 C 1344.677 1534.33 1396.137 1509.279 1450.877 1507.946 L 1893.517 1497.176 C 1978.027 1495.121 2027.657 1401.679 1982.187 1330.643 L 1743.607 957.976 C 1729.017 935.196 1719.507 909.536 1715.737 882.746 C 1711.957 855.966 1714.007 828.676 1721.737 802.756 L 1848.077 378.926 C 1872.157 298.176 1798.617 221.916 1716.767 243.186 L 1288.287 354.516 C 1262.097 361.326 1234.747 362.416 1208.097 357.716 C 1181.457 353.026 1156.127 342.646 1133.847 327.286 L 769.294 76.116 C 699.65 28.146 604.466 74.646 599.463 158.766 L 573.239 600.246 C 569.994 654.886 543.11 705.386 499.603 738.616 L 147.94 1007.216 C 80.914 1058.41 95.596 1163.196 174.303 1194.032 L 586.57 1355.546 C 637.55 1375.519 677.336 1416.659 695.55 1468.278 L 842.763 1885.449 Z",
27 | ),
28 | ]),
29 | svg.path([
30 | attribute("transform", "matrix(-1, 0, 0, -1, 0.000013, -0.000111)"),
31 | attribute(
32 | "style",
33 | "transform-box: fill-box; transform-origin: 50% 50%;",
34 | ),
35 | attribute("fill", "#151515"),
36 | attribute(
37 | "d",
38 | "M 918.91 1994.668 C 868.969 1985.861 823.32 1952.703 804.42 1899.152 L 657.186 1481.986 C 642.831 1441.315 611.52 1408.933 571.327 1393.185 L 159.044 1231.661 C 53.25 1190.213 32.792 1044.7 122.981 975.816 L 474.644 707.246 C 491.576 694.326 505.522 677.906 515.528 659.096 C 525.534 640.296 531.366 619.556 532.625 598.296 L 558.835 156.826 C 565.559 43.566 697.668 -20.864 791.287 43.616 L 791.289 43.626 L 1155.87 294.796 L 1155.87 294.806 C 1173.42 306.906 1193.38 315.086 1214.37 318.786 C 1235.37 322.486 1256.92 321.626 1277.55 316.256 L 1706.04 204.946 C 1816.06 176.356 1918.17 282.096 1885.75 390.836 L 1885.75 390.826 L 1759.42 814.646 C 1753.33 835.056 1751.72 856.536 1754.69 877.636 C 1757.66 898.726 1765.15 918.926 1776.65 936.856 L 1776.65 936.866 L 2015.25 1309.543 L 2015.24 1309.545 C 2076.44 1405.123 2007.46 1534.859 1893.87 1537.622 L 1451.21 1548.375 C 1408.06 1549.423 1367.56 1569.142 1340.17 1602.448 L 1059.15 1944.101 C 1023.08 1987.955 968.841 2003.497 918.896 1994.689 M 932.34 1918.443 C 955.035 1922.445 979.819 1914.605 997.365 1893.277 L 1278.38 1551.634 C 1320.52 1500.393 1382.95 1470.014 1449.26 1468.401 L 1891.92 1457.648 C 1947.35 1456.302 1977.63 1399.16 1947.86 1352.674 L 1709.27 979.996 C 1673.48 924.126 1663.8 855.366 1682.74 791.796 L 1809.08 367.986 C 1824.81 315.206 1779.83 268.436 1726.15 282.376 L 1297.66 393.696 C 1233.45 410.386 1165.09 398.326 1110.46 360.676 L 745.884 109.506 C 700.205 78.036 641.945 106.606 638.676 161.576 L 612.469 603.046 C 608.537 669.276 575.914 730.556 523.186 770.836 L 171.524 1039.406 C 127.662 1072.905 136.58 1136.956 188.203 1157.189 L 600.485 1318.715 C 662.256 1342.917 710.525 1392.816 732.608 1455.385 L 732.608 1455.387 L 879.842 1872.552 C 889.034 1898.593 909.64 1914.438 932.335 1918.441",
39 | ),
40 | ]),
41 | svg.path([
42 | attribute("transform", "matrix(-1, 0, 0, -1, -0.000262, 0.00008)"),
43 | attribute(
44 | "style",
45 | "transform-box: fill-box; transform-origin: 50% 50%;",
46 | ),
47 | attribute("fill", "#151515"),
48 | attribute(
49 | "d",
50 | "M 1340.734 989.33 C 1383.47 996.87 1412.007 1037.62 1404.473 1080.35 C 1396.939 1123.09 1356.188 1151.617 1313.452 1144.081 C 1270.716 1136.544 1242.179 1095.79 1249.713 1053.06 C 1257.247 1010.33 1297.999 981.79 1340.734 989.33 Z",
51 | ),
52 | ]),
53 | svg.path([
54 | attribute("transform", "matrix(-1, 0, 0, -1, 0.000102, -0.000003)"),
55 | attribute(
56 | "style",
57 | "transform-box: fill-box; transform-origin: 50% 50%;",
58 | ),
59 | attribute("fill", "#151515"),
60 | attribute(
61 | "d",
62 | "M 707.68 877.704 C 750.41 885.234 778.95 925.99 771.41 968.722 C 763.88 1011.455 723.13 1039.988 680.39 1032.451 C 637.66 1024.915 609.12 984.163 616.65 941.43 C 624.19 898.694 664.94 870.164 707.68 877.704 Z",
63 | ),
64 | ]),
65 | svg.path([
66 | attribute("transform", "matrix(-1, 0, 0, -1, 0.000158, -0.000137)"),
67 | attribute(
68 | "style",
69 | "transform-box: fill-box; transform-origin: 50% 50%;",
70 | ),
71 | attribute("fill", "#151515"),
72 | attribute(
73 | "d",
74 | "M 908.965 1220.696 C 904.065 1218.806 899.585 1215.966 895.775 1212.346 C 891.975 1208.726 888.915 1204.386 886.795 1199.586 C 884.665 1194.776 883.505 1189.606 883.375 1184.356 C 883.245 1179.106 884.155 1173.876 886.045 1168.976 C 890.985 1156.206 898.385 1144.526 907.835 1134.606 C 917.285 1124.686 928.595 1116.736 941.125 1111.196 L 941.135 1111.186 C 953.645 1105.646 967.125 1102.626 980.815 1102.296 L 980.825 1102.296 L 980.845 1102.296 C 994.525 1101.976 1008.135 1104.346 1020.905 1109.276 L 1020.915 1109.276 C 1033.685 1114.206 1045.365 1121.606 1055.275 1131.056 L 1055.285 1131.056 L 1055.285 1131.066 C 1065.195 1140.506 1073.145 1151.816 1078.685 1164.326 C 1084.245 1176.856 1087.265 1190.366 1087.585 1204.066 C 1087.835 1214.666 1083.865 1224.936 1076.545 1232.616 C 1069.215 1240.286 1059.145 1244.736 1048.535 1244.986 C 1043.285 1245.116 1038.065 1244.196 1033.165 1242.306 C 1028.265 1240.406 1023.785 1237.566 1019.985 1233.936 C 1016.185 1230.316 1013.135 1225.976 1011.015 1221.166 C 1008.895 1216.366 1007.735 1211.186 1007.615 1205.936 C 1007.535 1202.766 1006.835 1199.636 1005.545 1196.736 L 1005.545 1196.726 L 1005.535 1196.706 C 1004.245 1193.796 1002.395 1191.166 1000.095 1188.966 C 997.785 1186.766 995.075 1185.046 992.105 1183.906 L 992.085 1183.896 C 989.105 1182.746 985.935 1182.196 982.745 1182.266 C 979.565 1182.346 976.425 1183.056 973.515 1184.346 L 973.495 1184.346 L 973.485 1184.356 C 970.575 1185.646 967.955 1187.486 965.755 1189.786 L 965.755 1189.796 C 963.555 1192.106 961.835 1194.816 960.685 1197.796 C 958.795 1202.696 955.955 1207.176 952.335 1210.976 C 948.705 1214.776 944.375 1217.836 939.565 1219.956 C 934.765 1222.086 929.585 1223.246 924.335 1223.376 C 919.085 1223.506 913.865 1222.596 908.965 1220.696 Z",
75 | ),
76 | ]),
77 | svg.path([
78 | attribute(
79 | "d",
80 | "M 837.332 1799.273 L 1040.536 1392.732 L 1200.981 1417.848 L 1168.502 1900.5",
81 | ),
82 | attribute("style", "fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"),
83 | ]),
84 | svg.path([
85 | attribute(
86 | "d",
87 | "M 1462.463 1851.433 L 1464.166 1415.159 L 1635.88 1406.161 L 1804.306 1841.839",
88 | ),
89 | attribute("style", "fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"),
90 | ]),
91 | svg.g(
92 | [
93 | attribute(
94 | "transform",
95 | "matrix(5.468584, 0, 0, 5.468584, -10849.720703, -6934.962402)",
96 | ),
97 | attribute("style", ""),
98 | ],
99 | [
100 | svg.path([
101 | attribute("style", "paint-order: fill; fill: rgb(255, 251, 232);"),
102 | attribute("opacity", "0.2"),
103 | attribute(
104 | "d",
105 | "M 2200.077 1640.391 C 2200.077 1671.183 2166.744 1690.428 2140.077 1675.032 C 2113.41 1659.636 2113.41 1621.146 2140.077 1605.75 C 2146.158 1602.239 2153.055 1600.391 2160.077 1600.391 C 2182.168 1600.391 2200.077 1618.3 2200.077 1640.391 Z M 2288.077 1600.391 C 2257.285 1600.391 2238.04 1633.724 2253.436 1660.391 C 2268.832 1687.058 2307.322 1687.058 2322.718 1660.391 C 2326.229 1654.31 2328.077 1647.412 2328.077 1640.391 C 2328.077 1618.3 2310.168 1600.391 2288.077 1600.391 Z",
106 | ),
107 | ]),
108 | svg.path([
109 | attribute("style", "fill: rgb(47, 47, 47);"),
110 | attribute(
111 | "d",
112 | "M 2333.277 1624.261 C 2332.609 1622.397 2331.824 1620.576 2330.927 1618.811 L 2289.337 1524.191 C 2288.943 1523.28 2288.38 1522.452 2287.677 1521.751 C 2275.18 1509.251 2254.914 1509.251 2242.417 1521.751 C 2240.922 1523.248 2240.081 1525.276 2240.077 1527.391 L 2240.077 1552.391 L 2208.077 1552.391 L 2208.077 1527.391 C 2208.079 1525.269 2207.237 1523.233 2205.737 1521.731 C 2193.24 1509.231 2172.974 1509.231 2160.477 1521.731 C 2159.774 1522.432 2159.21 1523.26 2158.817 1524.171 L 2117.227 1618.791 C 2116.33 1620.556 2115.545 1622.377 2114.877 1624.241 C 2102.441 1659.036 2132.336 1694.245 2168.687 1687.617 C 2191.501 1683.457 2208.079 1663.581 2208.077 1640.391 L 2208.077 1568.391 L 2240.077 1568.391 L 2240.077 1640.391 C 2240.058 1677.341 2280.047 1700.456 2312.056 1681.997 C 2332.152 1670.408 2341.084 1646.106 2333.277 1624.261 Z M 2172.787 1532.141 C 2178.268 1527.526 2186.148 1527.118 2192.077 1531.141 L 2192.077 1604.651 C 2179.365 1593.237 2161.537 1589.457 2145.287 1594.731 L 2172.787 1532.141 Z M 2160.077 1672.391 C 2135.443 1672.391 2120.047 1645.724 2132.364 1624.391 C 2144.681 1603.058 2175.473 1603.058 2187.79 1624.391 C 2190.598 1629.256 2192.077 1634.774 2192.077 1640.391 C 2192.077 1658.064 2177.75 1672.391 2160.077 1672.391 Z M 2256.077 1531.131 C 2262.006 1527.108 2269.886 1527.516 2275.367 1532.131 L 2302.867 1594.711 C 2286.615 1589.44 2268.787 1593.223 2256.077 1604.641 L 2256.077 1531.131 Z M 2288.077 1672.391 C 2263.443 1672.391 2248.047 1645.724 2260.364 1624.391 C 2272.681 1603.058 2303.473 1603.058 2315.79 1624.391 C 2318.598 1629.256 2320.077 1634.774 2320.077 1640.391 C 2320.077 1658.064 2305.75 1672.391 2288.077 1672.391 Z",
113 | ),
114 | ]),
115 | ],
116 | ),
117 | ],
118 | )
119 | }
120 |
121 | fn tab(title: String, current: String, path: String, query_string: String) {
122 | let classes = case title == current {
123 | True -> "tab active"
124 | False -> "tab"
125 | }
126 |
127 | html.a([attribute.class(classes), attribute.href(path <> query_string)], [
128 | html.text(title),
129 | ])
130 | }
131 |
132 | pub fn render(current_tab: String, connection_status, query_string: String) {
133 | html.nav([attribute.class("topbar")], [
134 | html.div([attribute.class("logo")], [icon(), html.text("Spectator")]),
135 | html.div([attribute.class("filler")], []),
136 | html.div([attribute.class("tabs")], [
137 | tab("Dashboard", current_tab, "/dashboard", query_string),
138 | html.div([attribute.class("separator")], []),
139 | tab("Processes", current_tab, "/processes", query_string),
140 | html.div([attribute.class("separator")], []),
141 | tab("ETS", current_tab, "/ets", query_string),
142 | html.div([attribute.class("separator")], []),
143 | tab("Ports", current_tab, "/ports", query_string),
144 | html.div([attribute.class("separator")], []),
145 | ]),
146 | html.div([attribute.class("filler")], []),
147 | html.div([attribute.class("connection-status")], [
148 | html.text(connection_status),
149 | ]),
150 | ])
151 | }
152 |
--------------------------------------------------------------------------------
/src/spectator/internal/views/table.gleam:
--------------------------------------------------------------------------------
1 | //// Generic table rendering view functions
2 |
3 | import gleam/bool
4 | import gleam/list
5 | import lustre/attribute
6 | import lustre/element
7 | import lustre/element/html
8 | import lustre/event
9 | import spectator/internal/api
10 |
11 | pub fn heading(
12 | name: String,
13 | title: String,
14 | sort_criteria: s,
15 | current_sort_criteria: s,
16 | current_sort_direction: api.SortDirection,
17 | handle_heading_click: fn(s) -> a,
18 | align_right align_right: Bool,
19 | ) {
20 | html.th(
21 | [
22 | attribute.title(title),
23 | event.on_click(handle_heading_click(sort_criteria)),
24 | bool.guard(
25 | when: align_right,
26 | return: attribute.class("cell-right"),
27 | otherwise: attribute.none,
28 | ),
29 | ],
30 | [
31 | case current_sort_criteria == sort_criteria {
32 | True -> {
33 | case current_sort_direction {
34 | api.Ascending -> html.text("⬆")
35 | api.Descending -> html.text("⬇")
36 | }
37 | }
38 | False -> html.text("")
39 | },
40 | html.text(name),
41 | ],
42 | )
43 | }
44 |
45 | pub fn map_rows(list: List(a), with fun: fn(a) -> element.Element(b)) {
46 | map_and_append(
47 | list,
48 | fun,
49 | [],
50 | html.tr([attribute.class("buffer-row")], [html.td([], [])]),
51 | )
52 | }
53 |
54 | fn map_and_append(l: List(a), fun: fn(a) -> b, acc: List(b), item: b) -> List(b) {
55 | case l {
56 | [] -> list.reverse([item, ..acc])
57 | [x, ..xs] -> map_and_append(xs, fun, [fun(x), ..acc], item)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/spectator_ffi.erl:
--------------------------------------------------------------------------------
1 | -module(spectator_ffi).
2 | -export([
3 | get_status/3,
4 | get_state/3,
5 | sys_suspend/2,
6 | sys_resume/2,
7 | list_processes/1,
8 | get_process_info/2,
9 | format_pid/1,
10 | get_details/2,
11 | format_port/1,
12 | list_ets_tables/1,
13 | get_ets_data/2,
14 | new_ets_table/2,
15 | get_word_size/1,
16 | opaque_tuple_to_list/1,
17 | get_ets_table_info/2,
18 | compare_data/2,
19 | list_ports/1,
20 | get_port_info/2,
21 | get_port_details/2,
22 | pid_to_string/1,
23 | port_to_string/1,
24 | pid_from_string/1,
25 | port_from_string/1,
26 | get_memory_statistics/1,
27 | get_system_info/1,
28 | truncate_float/1,
29 | kill_process/2,
30 | set_cookie/2,
31 | hidden_connect_node/1
32 | ]).
33 |
34 | % ---------------------------------------------------
35 | % CALL HARNESS
36 | % ---------------------------------------------------
37 |
38 | -define(ERPC_TIMEOUT, 1000).
39 |
40 | % Make a function to the local or remote node.
41 | % NodeOption is a Gleam option, if passed as Some(node), the call is made via erpc to that node,
42 | % otherwise it is made locally to the current node via erlang:apply/3.
43 | do_call(NodeOption, Module, Function, Args) ->
44 | case NodeOption of
45 | none ->
46 | apply(Module, Function, Args);
47 | {some, NodeName} ->
48 | erpc:call(
49 | NodeName,
50 | Module,
51 | Function,
52 | Args,
53 | ?ERPC_TIMEOUT
54 | )
55 | end.
56 |
57 | % Wrap in try/catch and transform into a Gleam result
58 | to_result(Function) ->
59 | try
60 | {ok, Function()}
61 | catch
62 | % An error occurred with the ERPC call
63 | error:{erpc, Reason} -> {error, {erpc_error, Reason}};
64 | % Process info errors
65 | error:process_not_found -> {error, {not_found_error}};
66 | error:process_no_info -> {error, {no_info_error}};
67 | % Standard Erlang errors
68 | error:notsup -> {error, {not_supported_error}};
69 | error:badarg -> {error, {bad_argument_error}};
70 | % A function call unexpectedly returned a result of undefined
71 | % (we throw this error in nested functions to avoid returning undefined to Gleam)
72 | error:returned_undefined -> {error, returned_undefined_error};
73 | % Fallback for any other error
74 | _:Reason -> {error, {dynamic_error, Reason}}
75 | end.
76 |
77 | % ---------------------------------------------------
78 | % GENERAL HELPERS
79 | % ---------------------------------------------------
80 | % these are used inside ffi functions but not exported
81 |
82 | % Normalize a pid, port, or nif resource into a SystemPrimitive() type
83 | % also looks up the spectator tag for a pid if it is a process
84 | classify_system_primitive(Item) ->
85 | case Item of
86 | Process when is_pid(Process) ->
87 | {process_primitive, Process, get_process_name_option(Process),
88 | spectator_tag_manager:get_tag(Process)};
89 | Port when is_port(Port) -> {port_primitive, Port, get_port_name_option(Port)};
90 | NifResource ->
91 | {nif_resource_primitive, NifResource}
92 | end.
93 |
94 | % Get the name of a process wrapped in a Gleam Option type
95 | get_process_name_option(Pid) ->
96 | try
97 | case do_call(none, erlang, process_info, [Pid, registered_name]) of
98 | {registered_name, Name} -> {some, Name};
99 | _ -> none
100 | end
101 | catch
102 | error:badarg -> none
103 | end.
104 |
105 | % Get the name of a port wrapped in a Gleam Option type
106 | get_port_name_option(Port) ->
107 | try
108 | case do_call(none, erlang, port_info, [Port, name]) of
109 | {registered_name, Name} -> {some, Name};
110 | _ -> none
111 | end
112 | catch
113 | error:badarg -> none
114 | end.
115 |
116 | to_option(Input) ->
117 | case Input of
118 | [] -> none;
119 | undefined -> none;
120 | Value -> {some, Value}
121 | end.
122 |
123 | % ---------------------------------------------------
124 | % PROCESSES
125 | % ---------------------------------------------------
126 |
127 | % List all processes on a node
128 | list_processes(NodeOption) ->
129 | to_result(fun() ->
130 | do_call(NodeOption, erlang, processes, [])
131 | end).
132 |
133 | % Kill a process
134 | % https://www.erlang.org/doc/apps/erts/erlang.html#exit/2
135 | kill_process(NodeOption, Pid) ->
136 | to_result(fun() -> do_call(NodeOption, erlang, exit, [Pid, kill]) end).
137 |
138 | % Get the status of an OTP-compatible process
139 | % https://www.erlang.org/doc/apps/stdlib/sys.html#get_status/2
140 | get_status(NodeOption, Name, Timeout) ->
141 | to_result(fun() ->
142 | {status, Pid, {module, Module}, SItems} = do_call(NodeOption, sys, get_status, [
143 | Name, Timeout
144 | ]),
145 | {SysState, Parent} = extract_sysstate_and_parent(SItems),
146 | {status, Pid, Module, Parent, SysState, SItems}
147 | end).
148 |
149 | % Get the state of an OTP-compatible process or return an error
150 | % https://www.erlang.org/doc/apps/stdlib/sys.html#get_state/2
151 | get_state(NodeOption, Name, Timeout) ->
152 | to_result(fun() ->
153 | do_call(NodeOption, sys, get_state, [Name, Timeout])
154 | end).
155 |
156 | % From a list of sys state items, extract the sys state and parent into a tuple
157 | extract_sysstate_and_parent(SItems) ->
158 | extract_sysstate_and_parent(SItems, undefined, undefined).
159 |
160 | extract_sysstate_and_parent([], SysState, Parent) ->
161 | {SysState, Parent};
162 | extract_sysstate_and_parent([H | T], SysState, Parent) ->
163 | case H of
164 | running -> extract_sysstate_and_parent(T, process_running, Parent);
165 | suspended -> extract_sysstate_and_parent(T, process_suspended, Parent);
166 | Pid when is_pid(Pid) -> extract_sysstate_and_parent(T, SysState, Pid);
167 | _ -> extract_sysstate_and_parent(T, SysState, Parent)
168 | end.
169 |
170 | %
171 | sys_suspend(NodeOption, Pid) ->
172 | to_result(fun() -> do_call(NodeOption, sys, suspend, [Pid]) end).
173 |
174 | sys_resume(NodeOption, Pid) ->
175 | to_result(fun() -> do_call(NodeOption, sys, resume, [Pid]) end).
176 |
177 | % Throw an error if a value is undefined
178 | % !! THROWS - wrap in to_result
179 | assert_val(Value) ->
180 | case Value of
181 | undefined ->
182 | {
183 | error(returned_undefined)
184 | };
185 | Val ->
186 | Val
187 | end.
188 |
189 | % Get the info of a regular process for display in a list
190 | % https://www.erlang.org/doc/apps/erts/erlang.html#process_info/2
191 | % Return is typed as ProcessInfo() in Gleam
192 | get_process_info(NodeOption, Name) ->
193 | ItemList = [
194 | current_function,
195 | initial_call,
196 | registered_name,
197 | memory,
198 | message_queue_len,
199 | reductions,
200 | status
201 | ],
202 | to_result(fun() ->
203 | P = do_call(NodeOption, erlang, process_info, [Name, ItemList]),
204 | case P of
205 | undefined ->
206 | error(process_not_found);
207 | [] ->
208 | error(process_no_info);
209 | Info ->
210 | {_Keys, Values} = lists:unzip(Info),
211 | InfoTuple = list_to_tuple(Values),
212 | InfoNormalized = {
213 | % Prefix to turn into Info() type
214 | process_info,
215 | element(1, InfoTuple),
216 | element(2, InfoTuple),
217 | % Convert registered name to option type
218 | case element(3, InfoTuple) of
219 | [] -> none;
220 | RegisteredName -> {some, RegisteredName}
221 | end,
222 | element(4, InfoTuple),
223 | element(5, InfoTuple),
224 | element(6, InfoTuple),
225 | spectator_tag_manager:get_tag(Name),
226 | element(7, InfoTuple)
227 | },
228 | InfoNormalized
229 | end
230 | end).
231 |
232 | % Get additional details of a process for display in a details view
233 | get_details(NodeOption, Name) ->
234 | ItemList = [
235 | messages,
236 | links,
237 | monitored_by,
238 | monitors,
239 | trap_exit,
240 | parent
241 | ],
242 | to_result(fun() ->
243 | P = do_call(NodeOption, erlang, process_info, [Name, ItemList]),
244 | case P of
245 | undefined ->
246 | {error, not_found};
247 | [] ->
248 | {error, no_info};
249 | Info ->
250 | {_Keys, Values} = lists:unzip(Info),
251 | DetailsTuple = list_to_tuple(Values),
252 | DetailsNormalized = {
253 | % Prefix to turn into Details() type
254 | details,
255 | % Messages
256 | element(1, DetailsTuple),
257 | % Links -> we normalize into SystemPrimitive()
258 | lists:map(
259 | fun classify_system_primitive/1,
260 | element(2, DetailsTuple)
261 | ),
262 | % Monitored By -> we normalize into SystemPrimitive()
263 | lists:map(
264 | fun classify_system_primitive/1,
265 | element(3, DetailsTuple)
266 | ),
267 | % Monitors -> we normalize into SystemPrimitive()
268 | % There is a lot of remapping here, let's break it down:
269 | % - We receive the monitors either by id or name based on how they are monitored
270 | % - But we don't actually care if a resource is monitored by id or name
271 | % - We DO want to know the id of every resource, even if its is monitored by name
272 | % - We also want to know the name of the resource if it has one,
273 | % even if it is not monitored by that name
274 | % - For this reason we look up the respective id/name for each resource.
275 | % - We also handle the special case of a remote process monitored by name separately.
276 | % - In case an ID lookup fails, we filter out the resource.
277 | lists:filtermap(
278 | fun(Item) ->
279 | case Item of
280 | % Local process monitored by name
281 | {process, {RegName, Node}} when Node == node() ->
282 | case do_call(NodeOption, erlang, whereis, [RegName]) of
283 | % If the PID lookup fails, we filter this process out
284 | undefined ->
285 | false;
286 | Pid ->
287 | {true,
288 | {process_primitive, Pid, {some, RegName},
289 | spectator_tag_manager:get_tag(Pid)}}
290 | end;
291 | % Remote process monitored by name
292 | {process, {RegName, Node}} ->
293 | {true, {remote_process_primitive, RegName, Node}};
294 | % Local process monitored by pid
295 | {process, Pid} ->
296 | {true,
297 | {process_primitive, Pid, get_process_name_option(Pid),
298 | spectator_tag_manager:get_tag(Pid)}};
299 | % Port monitored by name
300 | % (Node is always the local node, it's a legacy field)
301 | {port, {RegName, _Node}} ->
302 | case do_call(NodeOption, erlang, whereis, [RegName]) of
303 | % If the Port ID lookup fails, we filter this port out
304 | undefined -> false;
305 | Port -> {true, {port_primitive, Port, RegName}}
306 | end;
307 | % Port monitored by port id
308 | {port, PortId} ->
309 | {true, {port_primitive, PortId, get_port_name_option(PortId)}}
310 | end
311 | end,
312 | element(4, DetailsTuple)
313 | ),
314 | % Trap Exit
315 | element(5, DetailsTuple),
316 | % Parent
317 | case element(6, DetailsTuple) of
318 | undefined -> none;
319 | Parent -> {some, classify_system_primitive(Parent)}
320 | end
321 | },
322 | DetailsNormalized
323 | end
324 | end).
325 |
326 | % ---------------------------------------------------
327 | % PORTS
328 | % ---------------------------------------------------
329 |
330 | % Extract the second element from a two element tuple, thrwoing an error if it is undefined
331 | % For use in decoding port info responses.
332 | % !! THROWS - wrap in to_result
333 | extract_val(Tuple) ->
334 | assert_val(element(2, Tuple)).
335 |
336 | list_ports(NodeOption) ->
337 | to_result(fun() ->
338 | do_call(NodeOption, erlang, ports, [])
339 | end).
340 |
341 | % Get the info of a port for display in a list
342 | % https://www.erlang.org/doc/apps/erts/erlang.html#port_info/2
343 | % Return is typed as PortInfo() in Gleam
344 | get_port_info(NodeOption, Port) ->
345 | to_result(fun() ->
346 | {port_info,
347 | % Port name, this is the command that the port was started with
348 | list_to_bitstring(
349 | extract_val(
350 | do_call(NodeOption, erlang, port_info, [Port, name])
351 | )
352 | ),
353 | % Registered name, if the port is registered
354 | to_option(
355 | do_call(NodeOption, erlang, port_info, [Port, registered_name])
356 | ),
357 | % Process connected to the port
358 | classify_system_primitive(
359 | extract_val(
360 | do_call(NodeOption, erlang, port_info, [Port, connected])
361 | )
362 | ),
363 | % Operating system process ID
364 | to_option(
365 | element(
366 | 2,
367 | do_call(NodeOption, erlang, port_info, [Port, os_pid])
368 | )
369 | ),
370 | % Input bytes
371 | extract_val(
372 | do_call(NodeOption, erlang, port_info, [Port, input])
373 | ),
374 | % Output bytes
375 | extract_val(
376 | do_call(NodeOption, erlang, port_info, [Port, output])
377 | ),
378 | % Memory used by the port
379 | extract_val(
380 | do_call(NodeOption, erlang, port_info, [Port, memory])
381 | ),
382 | % Queue size
383 | extract_val(
384 | do_call(NodeOption, erlang, port_info, [Port, queue_size])
385 | )}
386 | end).
387 |
388 | % Get the details of a port for display in a details view
389 | % https://www.erlang.org/doc/apps/erts/erlang.html#port_info/2
390 | % Return is typed as PortDetails() in Gleam
391 | get_port_details(NodeOption, Port) ->
392 | to_result(fun() ->
393 | {port_details,
394 | % Links -> we normalize into SystemPrimitive()
395 | lists:map(
396 | fun classify_system_primitive/1,
397 | extract_val(
398 | do_call(NodeOption, erlang, port_info, [Port, links])
399 | )
400 | ),
401 | % Monitored By -> we normalize into SystemPrimitive()
402 | lists:map(
403 | fun classify_system_primitive/1,
404 | extract_val(
405 | do_call(NodeOption, erlang, port_info, [Port, monitored_by])
406 | )
407 | ),
408 | % Monitors -> we normalize into SystemPrimitive()
409 | lists:map(
410 | fun classify_system_primitive/1,
411 | extract_val(
412 | do_call(NodeOption, erlang, port_info, [Port, monitors])
413 | )
414 | )}
415 | end).
416 |
417 | % ---------------------------------------------------
418 | % ETS
419 | % ---------------------------------------------------
420 |
421 | % Construct a Table() type from an ETS table ID
422 | % !! THROWS - wrap in to_result
423 | build_table_info(NodeOption, Table) ->
424 | {table, assert_val(do_call(NodeOption, ets, info, [Table, id])),
425 | assert_val(do_call(NodeOption, ets, info, [Table, name])),
426 | assert_val(do_call(NodeOption, ets, info, [Table, type])),
427 | assert_val(do_call(NodeOption, ets, info, [Table, size])),
428 | assert_val(do_call(NodeOption, ets, info, [Table, memory])),
429 | classify_system_primitive(assert_val(do_call(NodeOption, ets, info, [Table, owner]))),
430 | assert_val(do_call(NodeOption, ets, info, [Table, protection])),
431 | assert_val(do_call(NodeOption, ets, info, [Table, read_concurrency])),
432 | assert_val(do_call(NodeOption, ets, info, [Table, write_concurrency]))}.
433 |
434 | % Return a list of all ETS tables on the node,
435 | % already populated with information, as Table() types.
436 | list_ets_tables(NodeOption) ->
437 | to_result(fun() ->
438 | lists:map(
439 | fun(Table) -> build_table_info(NodeOption, Table) end,
440 | do_call(NodeOption, ets, all, [])
441 | )
442 | end).
443 |
444 | % Return the info of a single ETS table as a Table() type
445 | get_ets_table_info(NodeOption, Table) ->
446 | to_result(fun() ->
447 | case do_call(NodeOption, ets, info, [Table, id]) of
448 | undefined -> error(returned_undefined);
449 | TableId -> build_table_info(NodeOption, TableId)
450 | end
451 | end).
452 |
453 | % Return the data of an ETS table
454 | get_ets_data(NodeOption, Table) ->
455 | to_result(fun() ->
456 | do_call(NodeOption, ets, match, [Table, '$1'])
457 | end).
458 |
459 | % Create a new ETS table
460 | new_ets_table(NodeOption, Name) ->
461 | to_result(fun() ->
462 | do_call(NodeOption, ets, new, [Name, [named_table, public]])
463 | end).
464 |
465 | % ---------------------------------------------------
466 | % SYSTEM INFO
467 | % ---------------------------------------------------
468 |
469 | % Get system info, as a SystemInfo() type
470 | get_system_info(NodeOption) ->
471 | to_result(fun() ->
472 | {
473 | system_info,
474 | % Uptime String
475 | uptime_string(NodeOption),
476 | % Architecture
477 | list_to_bitstring(
478 | do_call(NodeOption, erlang, system_info, [system_architecture])
479 | ),
480 | % ERTS version
481 | list_to_bitstring(
482 | do_call(NodeOption, erlang, system_info, [version])
483 | ),
484 | % OTP release
485 | list_to_bitstring(
486 | do_call(NodeOption, erlang, system_info, [otp_release])
487 | ),
488 | % Schedulers
489 | do_call(NodeOption, erlang, system_info, [schedulers]),
490 | % Schedulers online
491 | do_call(NodeOption, erlang, system_info, [schedulers_online]),
492 | % Atom count
493 | do_call(NodeOption, erlang, system_info, [atom_count]),
494 | % Atom limit
495 | do_call(NodeOption, erlang, system_info, [atom_limit]),
496 | % ETS count
497 | do_call(NodeOption, erlang, system_info, [ets_count]),
498 | % ETS limit
499 | do_call(NodeOption, erlang, system_info, [ets_limit]),
500 | % Port count
501 | do_call(NodeOption, erlang, system_info, [port_count]),
502 | % Port limit
503 | do_call(NodeOption, erlang, system_info, [port_limit]),
504 | % Process count
505 | do_call(NodeOption, erlang, system_info, [process_count]),
506 | % Process limit
507 | do_call(NodeOption, erlang, system_info, [process_limit])
508 | }
509 | end).
510 |
511 | % Get memory stats
512 | % as a MemoryStatistics() type
513 | get_memory_statistics(NodeOption) ->
514 | to_result(fun() ->
515 | list_to_tuple([
516 | memory_statistics
517 | | element(
518 | 2,
519 | lists:unzip(
520 | do_call(NodeOption, erlang, memory, [])
521 | )
522 | )
523 | ])
524 | end).
525 |
526 | get_word_size(NodeOption) ->
527 | to_result(fun() ->
528 | do_call(NodeOption, erlang, system_info, [wordsize])
529 | end).
530 |
531 | % !! THROWS - wrap in to_result
532 | uptime_seconds(NodeOption) ->
533 | NativeUptime =
534 | do_call(NodeOption, erlang, monotonic_time, []) -
535 | do_call(none, erlang, system_info, [start_time]),
536 | do_call(NodeOption, erlang, convert_time_unit, [NativeUptime, native, seconds]).
537 |
538 | % !! THROWS - wrap in to_result
539 | uptime_string(NodeOption) ->
540 | {D, {H, M, S}} =
541 | do_call(NodeOption, calendar, seconds_to_daystime, [uptime_seconds(NodeOption)]),
542 | list_to_bitstring(
543 | io_lib:format("~p days ~p hours ~p minutes ~p seconds", [D, H, M, S])
544 | ).
545 |
546 | % ---------------------------------------------------
547 | % LOCAL HELPERS
548 | % ---------------------------------------------------
549 | % (these are exported but are not related to node inspection)
550 |
551 | hidden_connect_node(Node) ->
552 | to_result(fun() -> net_kernel:hidden_connect_node(Node) end).
553 |
554 | pid_to_string(Pid) ->
555 | list_to_bitstring(pid_to_list(Pid)).
556 |
557 | set_cookie(Node, Cookie) ->
558 | to_result(fun() -> erlang:set_cookie(Node, Cookie) end).
559 |
560 | truncate_float(F) ->
561 | list_to_bitstring(io_lib:format("~.2f", [F])).
562 |
563 | port_to_string(Port) ->
564 | list_to_bitstring(port_to_list(Port)).
565 |
566 | pid_from_string(String) ->
567 | try
568 | {ok, list_to_pid(bitstring_to_list(String))}
569 | catch
570 | error:badarg -> {error, nil}
571 | end.
572 |
573 | port_from_string(String) ->
574 | try
575 | {ok, list_to_port(bitstring_to_list(String))}
576 | catch
577 | error:badarg -> {error, nil}
578 | end.
579 |
580 | format_pid(Pid) ->
581 | list_to_bitstring(pid_to_list(Pid)).
582 |
583 | format_port(Port) ->
584 | list_to_bitstring(port_to_list(Port)).
585 |
586 | compare_data(Data1, Data2) when Data1 < Data2 ->
587 | lt;
588 | compare_data(Data1, Data2) when Data1 > Data2 ->
589 | gt;
590 | compare_data(_Data1, _Data2) ->
591 | eq.
592 |
593 | opaque_tuple_to_list(Tuple) ->
594 | tuple_to_list(Tuple).
595 |
--------------------------------------------------------------------------------
/src/spectator_tag_manager.erl:
--------------------------------------------------------------------------------
1 | -module(spectator_tag_manager).
2 | -behaviour(gen_server).
3 |
4 | -define(TABLE_NAME, spectator_process_tags).
5 | -define(SERVER_NAME, spectator_tag_manager).
6 |
7 | %% API
8 | -export([start_link/0, add_tag/2, get_tag/1]).
9 |
10 | %% gen_server callbacks
11 | -export([init/1, handle_call/3, handle_info/2, terminate/2, code_change/3, handle_cast/2]).
12 |
13 | %% API Functions
14 | start_link() ->
15 | gen_server:start_link({local, ?SERVER_NAME}, ?MODULE, [], []).
16 |
17 | add_tag(Pid, Tag) when is_pid(Pid), is_binary(Tag) ->
18 | case whereis(?SERVER_NAME) of
19 | % server is not running, do nothing
20 | undefined -> ok;
21 | _ -> gen_server:call(?SERVER_NAME, {add_tag, Pid, Tag})
22 | end.
23 |
24 | get_tag(Pid) when is_pid(Pid) ->
25 | try
26 | case ets:lookup(?TABLE_NAME, Pid) of
27 | [{_, Tag}] -> {some, Tag};
28 | _ -> none
29 | end
30 | catch
31 | _:_Reason -> none
32 | end.
33 |
34 | %% gen_server callbacks
35 | init([]) ->
36 | ets:new(?TABLE_NAME, [named_table, public, set]),
37 | {ok, undefined}.
38 |
39 | handle_call({add_tag, Pid, Tag}, _From, _State) ->
40 | ets:insert(?TABLE_NAME, {Pid, Tag}),
41 | monitor(process, Pid),
42 | {reply, ok, undefined}.
43 |
44 | handle_cast(_, State) -> {noreply, State}.
45 |
46 | handle_info({'DOWN', _Ref, process, Pid, _Reason}, _State) ->
47 | ets:delete(?TABLE_NAME, Pid),
48 | {noreply, undefined}.
49 |
50 | terminate(_Reason, _State) ->
51 | ok.
52 |
53 | code_change(_OldVsn, State, _Extra) ->
54 | {ok, State}.
55 |
--------------------------------------------------------------------------------
/test/internal/api_local_test.gleam:
--------------------------------------------------------------------------------
1 | //// Test cases that run api requests against the local node
2 |
3 | import carpenter/table
4 | import gleam/dynamic
5 | import gleam/erlang/atom
6 | import gleam/erlang/port
7 | import gleam/erlang/process
8 | import gleam/list
9 | import gleam/option.{None, Some}
10 | import gleeunit/should
11 | import spectator/internal/api
12 | import utils/pantry
13 |
14 | // ------ SORTING
15 |
16 | pub fn invert_sort_direction_test() {
17 | api.invert_sort_direction(api.Ascending)
18 | |> should.equal(api.Descending)
19 |
20 | api.invert_sort_direction(api.Descending)
21 | |> should.equal(api.Ascending)
22 | }
23 |
24 | // [...]
25 |
26 | // ------ DATA FETCHING AND PROCESSING
27 |
28 | // -------[PROCESS LIST]
29 |
30 | pub fn get_process_list_test() {
31 | let assert Ok(pantry_actor) = pantry.new()
32 | let pantry_pid = process.subject_owner(pantry_actor)
33 |
34 | let sample =
35 | api.get_process_list(None)
36 | |> list.find(fn(pi) { pi.pid == pantry_pid })
37 | |> should.be_ok
38 |
39 | sample.info.current_function
40 | |> should.equal(#(
41 | atom.create_from_string("gleam_erlang_ffi"),
42 | atom.create_from_string("select"),
43 | 2,
44 | ))
45 |
46 | sample.info.initial_call
47 | |> should.equal(#(
48 | atom.create_from_string("erlang"),
49 | atom.create_from_string("apply"),
50 | 2,
51 | ))
52 |
53 | sample.info.registered_name
54 | |> should.be_none()
55 |
56 | sample.info.tag
57 | |> should.be_none()
58 |
59 | sample.info.status
60 | |> should.equal(atom.create_from_string("waiting"))
61 |
62 | dynamic.from(sample.info.memory)
63 | |> dynamic.classify()
64 | |> should.equal("Int")
65 |
66 | dynamic.from(sample.info.message_queue_len)
67 | |> dynamic.classify()
68 | |> should.equal("Int")
69 |
70 | dynamic.from(sample.info.reductions)
71 | |> dynamic.classify()
72 | |> should.equal("Int")
73 | }
74 |
75 | pub fn list_processes_test() {
76 | // Start a process
77 | let assert Ok(sub) = pantry.new()
78 | let pid = process.subject_owner(sub)
79 |
80 | // Check that the process is in the list of processes
81 | api.list_processes(None)
82 | |> should.be_ok
83 | |> list.find(fn(p) { p == pid })
84 | |> should.be_ok
85 | }
86 |
87 | pub fn get_process_info_test() {
88 | // Start a process
89 | let assert Ok(sub) = pantry.new()
90 | let pid = process.subject_owner(sub)
91 |
92 | // Retrieve the process info for that process
93 | let info =
94 | api.get_process_info(None, pid)
95 | |> should.be_ok
96 |
97 | info.current_function
98 | |> should.equal(#(
99 | atom.create_from_string("gleam_erlang_ffi"),
100 | atom.create_from_string("select"),
101 | 2,
102 | ))
103 |
104 | info.initial_call
105 | |> should.equal(#(
106 | atom.create_from_string("erlang"),
107 | atom.create_from_string("apply"),
108 | 2,
109 | ))
110 |
111 | info.registered_name
112 | |> should.be_none()
113 |
114 | info.tag
115 | |> should.be_none()
116 |
117 | info.status
118 | |> should.equal(atom.create_from_string("waiting"))
119 |
120 | dynamic.from(info.memory)
121 | |> dynamic.classify()
122 | |> should.equal("Int")
123 |
124 | dynamic.from(info.message_queue_len)
125 | |> dynamic.classify()
126 | |> should.equal("Int")
127 |
128 | dynamic.from(info.reductions)
129 | |> dynamic.classify()
130 | |> should.equal("Int")
131 | }
132 |
133 | pub fn get_process_info_failure_test() {
134 | // If this test is flakey, think of something else
135 | // (or remove it hehe)
136 | let assert Ok(pid) = api.decode_pid("<0.255.0>")
137 | api.get_process_info(None, pid)
138 | |> should.be_error
139 | }
140 |
141 | // -------[PROCESS DETAILS]
142 |
143 | pub fn get_details_test() {
144 | // Start a process
145 | let assert Ok(sub) = pantry.new()
146 | let pid = process.subject_owner(sub)
147 | let details =
148 | api.get_details(None, pid)
149 | |> should.be_ok()
150 |
151 | case details.parent {
152 | Some(api.ProcessPrimitive(pid, _, _)) -> {
153 | pid
154 | |> should.equal(process.self())
155 | }
156 | _ -> {
157 | should.fail()
158 | }
159 | }
160 |
161 | case details.links {
162 | [api.ProcessPrimitive(pid, _, _)] -> {
163 | pid
164 | |> should.equal(process.self())
165 | }
166 | _ -> {
167 | should.fail()
168 | }
169 | }
170 | }
171 |
172 | // -------[OTP PROCESS DETAILS]
173 |
174 | pub fn get_status_test() {
175 | // Start an OTP actor
176 | let assert Ok(sub) = pantry.new()
177 | let pid = process.subject_owner(sub)
178 |
179 | // Retrieve the status for that actor
180 | let status =
181 | api.get_status(None, pid, 500)
182 | |> should.be_ok
183 |
184 | let assert Ok(actor_module_name) = atom.from_string("gleam@otp@actor")
185 |
186 | status.module
187 | |> should.equal(actor_module_name)
188 |
189 | status.pid
190 | |> should.equal(pid)
191 |
192 | status.parent
193 | |> should.equal(process.self())
194 |
195 | status.sys_state
196 | |> should.equal(api.ProcessRunning)
197 | }
198 |
199 | pub fn get_status_failure_test() {
200 | // This is a non-OTP process, so it should fail
201 | let pid = process.start(fn() { Nil }, True)
202 | api.get_status(None, pid, 500)
203 | |> should.be_error
204 | }
205 |
206 | pub fn get_state_test() {
207 | let assert Ok(sub) = pantry.new()
208 | let pid = process.subject_owner(sub)
209 |
210 | pantry.add_item(sub, "This actor has some state")
211 |
212 | let actual_state =
213 | pantry.list_items(sub)
214 | |> dynamic.from()
215 |
216 | let inspected_state =
217 | api.get_state(None, pid, 500)
218 | |> should.be_ok()
219 |
220 | should.equal(inspected_state, actual_state)
221 | }
222 |
223 | pub fn get_state_failure_test() {
224 | // This is a non-OTP process, so it should fail
225 | let pid = process.start(fn() { Nil }, True)
226 | api.get_state(None, pid, 500)
227 | |> should.be_error
228 | }
229 |
230 | // -------[ETS]
231 |
232 | pub fn list_ets_tables_test() {
233 | // Create a test table
234 | let assert Ok(example) =
235 | table.build("test_table")
236 | |> table.privacy(table.Protected)
237 | |> table.write_concurrency(table.NoWriteConcurrency)
238 | |> table.read_concurrency(True)
239 | |> table.decentralized_counters(True)
240 | |> table.compression(False)
241 | |> table.set
242 |
243 | // Insert values
244 | example
245 | |> table.insert([#("hello", "joe")])
246 |
247 | let tables =
248 | api.list_ets_tables(None)
249 | |> should.be_ok
250 |
251 | let test_table =
252 | list.find(tables, fn(t) { t.name == atom.create_from_string("test_table") })
253 | |> should.be_ok
254 |
255 | test_table.table_type
256 | |> should.equal(atom.create_from_string("set"))
257 |
258 | test_table.size
259 | |> should.equal(1)
260 |
261 | test_table.memory
262 | |> dynamic.from()
263 | |> dynamic.classify()
264 | |> should.equal("Int")
265 |
266 | case test_table.owner {
267 | api.ProcessPrimitive(pid, _, _) -> {
268 | pid
269 | |> should.equal(process.self())
270 | }
271 | _ -> {
272 | should.fail()
273 | }
274 | }
275 |
276 | test_table.protection
277 | |> should.equal(atom.create_from_string("protected"))
278 |
279 | test_table.write_concurrency
280 | |> should.be_false()
281 |
282 | test_table.read_concurrency
283 | |> should.be_true()
284 | }
285 |
286 | pub fn get_ets_table_info_test() {
287 | // Create a test table
288 | let assert Ok(example) =
289 | table.build("test_table_ordered_set")
290 | |> table.privacy(table.Public)
291 | |> table.write_concurrency(table.WriteConcurrency)
292 | |> table.read_concurrency(True)
293 | |> table.decentralized_counters(True)
294 | |> table.compression(False)
295 | |> table.ordered_set
296 |
297 | // Insert values
298 | example
299 | |> table.insert([#("hello", "joe")])
300 |
301 | let info =
302 | api.get_ets_table_info(
303 | None,
304 | atom.create_from_string("test_table_ordered_set"),
305 | )
306 | |> should.be_ok
307 |
308 | info.table_type
309 | |> should.equal(atom.create_from_string("ordered_set"))
310 |
311 | info.size
312 | |> should.equal(1)
313 |
314 | info.memory
315 | |> dynamic.from()
316 | |> dynamic.classify()
317 | |> should.equal("Int")
318 |
319 | case info.owner {
320 | api.ProcessPrimitive(pid, _, _) -> {
321 | pid
322 | |> should.equal(process.self())
323 | }
324 | _ -> {
325 | should.fail()
326 | }
327 | }
328 |
329 | info.protection
330 | |> should.equal(atom.create_from_string("public"))
331 |
332 | info.write_concurrency
333 | |> should.be_true()
334 |
335 | info.read_concurrency
336 | |> should.be_true()
337 | }
338 |
339 | pub fn get_ets_data_test() {
340 | // Create a test table
341 | let assert Ok(example) =
342 | table.build("test_table_data")
343 | |> table.privacy(table.Public)
344 | |> table.write_concurrency(table.WriteConcurrency)
345 | |> table.read_concurrency(True)
346 | |> table.decentralized_counters(True)
347 | |> table.compression(False)
348 | |> table.set
349 |
350 | // Insert values
351 | example
352 | |> table.insert([#("hello", "joe"), #("hello_2", "mike")])
353 |
354 | let table =
355 | api.get_ets_table_info(None, atom.create_from_string("test_table_data"))
356 | |> should.be_ok
357 |
358 | let data =
359 | api.get_ets_data(None, table)
360 | |> should.be_ok
361 | |> api.sort_table_data(0, api.Ascending)
362 |
363 | data
364 | |> should.equal(api.TableData(
365 | content: [
366 | [dynamic.from("hello"), dynamic.from("joe")],
367 | [dynamic.from("hello_2"), dynamic.from("mike")],
368 | ],
369 | max_length: 2,
370 | ))
371 | }
372 |
373 | // -------[PORTS]
374 |
375 | pub fn get_port_list_test() {
376 | // Port process that sleeps for 10 seconds then exits
377 | let mock_port = open_port(#(atom.create_from_string("spawn"), "sleep 10"), [])
378 | let owner_primitive = api.ProcessPrimitive(process.self(), None, None)
379 |
380 | let ports = api.get_port_list(None)
381 |
382 | { list.length(ports) > 0 }
383 | |> should.be_true()
384 |
385 | let mock_port_item =
386 | list.find(ports, fn(p) { p.info.connected_process == owner_primitive })
387 | |> should.be_ok
388 |
389 | mock_port_item.port_id
390 | |> should.equal(mock_port)
391 |
392 | mock_port_item.info.memory
393 | |> dynamic.from()
394 | |> dynamic.classify()
395 | |> should.equal("Int")
396 |
397 | mock_port_item.info.queue_size
398 | |> dynamic.from()
399 | |> dynamic.classify()
400 | |> should.equal("Int")
401 |
402 | mock_port_item.info.os_pid
403 | |> should.be_some()
404 | |> dynamic.from()
405 | |> dynamic.classify()
406 | |> should.equal("Int")
407 |
408 | mock_port_item.info.input
409 | |> dynamic.from()
410 | |> dynamic.classify()
411 | |> should.equal("Int")
412 |
413 | mock_port_item.info.output
414 | |> dynamic.from()
415 | |> dynamic.classify()
416 | |> should.equal("Int")
417 |
418 | mock_port_item.info.registered_name
419 | |> should.be_none()
420 |
421 | mock_port_item.info.command_name
422 | |> should.equal("sleep 10")
423 | }
424 |
425 | pub fn get_port_details_test() {
426 | // Port process that sleeps for 10 seconds then exits
427 | let mock_port = open_port(#(atom.create_from_string("spawn"), "sleep 10"), [])
428 | link_port(mock_port)
429 | let owner_primitive = api.ProcessPrimitive(process.self(), None, None)
430 | let details =
431 | api.get_port_details(None, mock_port)
432 | |> should.be_ok
433 |
434 | details.links
435 | |> should.equal([owner_primitive])
436 |
437 | details.monitored_by
438 | |> should.equal([])
439 |
440 | details.monitors
441 | |> should.equal([])
442 | }
443 |
444 | // ------- [SYSTEM STATISTICS]
445 |
446 | pub fn get_memory_statistics_test() {
447 | let stats =
448 | api.get_memory_statistics(None)
449 | |> should.be_ok()
450 |
451 | { stats.processes + stats.system }
452 | |> should.equal(stats.total)
453 | }
454 |
455 | pub fn get_word_size_test() {
456 | let word_size =
457 | api.get_word_size(None)
458 | |> should.be_ok()
459 |
460 | word_size
461 | |> should.equal(8)
462 | }
463 |
464 | pub fn get_system_info_test() {
465 | let info =
466 | api.get_system_info(None)
467 | |> should.be_ok()
468 |
469 | info.otp_release
470 | |> should.equal("27")
471 | }
472 |
473 | // ------- TAG MANAGER GEN_SERVER
474 |
475 | pub fn tag_test() {
476 | // Should be able to add tag
477 | let assert Ok(sub) = pantry.new()
478 | let pid = process.subject_owner(sub)
479 |
480 | // Ignore result because it might already be running
481 | // (would return already started error)
482 | let _ = api.start_tag_manager()
483 |
484 | api.add_tag(pid, "test tag")
485 |
486 | api.get_tag(pid)
487 | |> should.be_some
488 | |> should.equal("test tag")
489 |
490 | // Tag should be removed when process is killed
491 | pantry.close(sub)
492 | process.sleep(10)
493 |
494 | api.get_tag(pid)
495 | |> should.be_none
496 | }
497 |
498 | pub fn tag_info_test() {
499 | let assert Ok(sub) = pantry.new()
500 | let pid = process.subject_owner(sub)
501 |
502 | // Ignore result because it might already be running
503 | // (would return already started error)
504 | let _ = api.start_tag_manager()
505 | api.add_tag(pid, "test tag")
506 |
507 | let info =
508 | api.get_process_info(None, pid)
509 | |> should.be_ok
510 |
511 | info.tag
512 | |> should.be_some
513 | |> should.equal("test tag")
514 | }
515 |
516 | @external(erlang, "erlang", "open_port")
517 | fn open_port(
518 | command: #(atom.Atom, String),
519 | options: List(dynamic.Dynamic),
520 | ) -> port.Port
521 |
522 | @external(erlang, "erlang", "link")
523 | fn link_port(p: port.Port) -> Nil
524 |
--------------------------------------------------------------------------------
/test/playground.gleam:
--------------------------------------------------------------------------------
1 | import carpenter/table
2 | import gleam/erlang/atom
3 | import gleam/erlang/process
4 | import spectator
5 | import utils/pantry
6 |
7 | pub fn main() {
8 | // Start the spectator service
9 | let assert Ok(_) = spectator.start()
10 |
11 | // Start an OTP actor
12 | let assert Ok(sub) = pantry.new()
13 |
14 | // Tag the actor with a name for easier identification in the spectator UI
15 | // Note: this only works because we called spectator.start before!
16 | sub
17 | |> spectator.tag_subject("Pantry Actor")
18 |
19 | // Add some state to the actor
20 | pantry.add_item(sub, "This actor has some state")
21 | pantry.add_item(sub, "Another item in the state of this actor")
22 | pantry.add_item(sub, "And some more state I've put into this demo actor")
23 |
24 | // Start another OTP actor
25 | let assert Ok(sub2) =
26 | pantry.new()
27 | |> spectator.tag_result("Registered Pantry Actor")
28 | pantry.add_item(sub2, "Helllo")
29 | let pid = process.subject_owner(sub2)
30 | // register the actor under a name
31 | register(atom.create_from_string("registered_actor"), pid)
32 |
33 | monitor_by_name(
34 | atom.create_from_string("process"),
35 | atom.create_from_string("registered_actor"),
36 | )
37 |
38 | // Create some tables
39 | let assert Ok(t1) =
40 | table.build("https://table-is-not-urlsafe/lol.com?query=1")
41 | |> table.set
42 |
43 | t1
44 | |> table.insert([#("hello", "joe")])
45 |
46 | // let assert Ok(_) = chrobot.open(browser, "http://127.0.0.1:3000/processes/", 5_000)
47 | // Sleep on the main process so the program doesn't exit
48 | spectator.tag(process.self(), "Playground Main")
49 | process.sleep_forever()
50 | }
51 |
52 | @external(erlang, "erlang", "register")
53 | fn register(name: atom.Atom, pid: process.Pid) -> Nil
54 |
55 | @external(erlang, "erlang", "monitor")
56 | fn monitor_by_name(resource: atom.Atom, name: atom.Atom) -> Nil
57 |
--------------------------------------------------------------------------------
/test/spectator_test.gleam:
--------------------------------------------------------------------------------
1 | import gleeunit
2 |
3 | pub fn main() {
4 | gleeunit.main()
5 | }
6 |
--------------------------------------------------------------------------------
/test/utils/pantry.gleam:
--------------------------------------------------------------------------------
1 | //// An implementation of a "pantry" using an actor.
2 | //// Taken from this guide:
3 | //// https://github.com/bcpeinhardt/learn_otp_with_gleam
4 |
5 | import gleam/erlang/process.{type Subject}
6 | import gleam/otp/actor
7 | import gleam/set.{type Set}
8 |
9 | // Below this comment are the public functions that we want to expose to other modules.
10 | // Some things to note:
11 | // - The functions all take a `Subject(Message)` as their first argument.
12 | // We use this to send messages to the actor. We could abstract that away further,
13 | // so the user doesn't have to manage the subject themselves, but following this
14 | // pattern will help us integrate with something called a `supervisor` later on, and it
15 | // matches how working with normal data in Gleam usually works too `module.operation(subject, arg1, arg2...)`.
16 | // - Functions that need to get a message back from the actor use `actor.call` to send a message
17 | // and wait for a reply. (this is just a re-export off `process.call`). It is a synchronous operation,
18 | // and it will block the calling process.
19 | // - Functions that don't need a reply use `actor.send` (also a re-export) to send a message to the actor.
20 | // These are asynchronous. No checking is done to see if the message was received or not, it's fire and forget.
21 |
22 | const timeout: Int = 5000
23 |
24 | /// Create a new pantry actor.
25 | /// If the actor starts successfully, we get back an
26 | /// `Ok(subject)` which we can use to send messages to the actor.
27 | pub fn new() -> Result(Subject(Message), actor.StartError) {
28 | actor.start(set.new(), handle_message)
29 | }
30 |
31 | /// Add an item to the pantry.
32 | pub fn add_item(pantry: Subject(Message), item: String) -> Nil {
33 | actor.send(pantry, AddItem(item))
34 | }
35 |
36 | /// Take an item from the pantry.
37 | pub fn take_item(pantry: Subject(Message), item: String) -> Result(String, Nil) {
38 | // See that `_`? That's a placeholder for the reply subject. It will be injected for us by `call`.
39 | //
40 | // If the underscore syntax is confusing, it's called a [function capture](https://tour.gleam.run/functions/function-captures/).
41 | // It's a shorthand for `fn(reply_with) { TakeItem(reply_with, item) }` where `reply_with` is a subject owned by
42 | // the calling process. Two way message passing requires two subjects, one for each process.
43 | //
44 | // Also, since we need to wait for a response, we pass a timeout as the last argument so we don't get stuck
45 | // waiting forever if our actor process gets struck by lightning or something.
46 | actor.call(pantry, TakeItem(_, item), timeout)
47 | }
48 |
49 | pub fn list_items(pantry: Subject(Message)) -> Set(String) {
50 | actor.call(pantry, ListItems(_), timeout)
51 | }
52 |
53 | /// Close the pantry.
54 | /// Shutdown functions like this are often written for manual usage and testing purposes.
55 | /// In a real application, you'd probably want to use a `supervisor` to manage the lifecycle of your actors.
56 | pub fn close(pantry: Subject(Message)) -> Nil {
57 | actor.send(pantry, Shutdown)
58 | }
59 |
60 | // That's our entire public API! Now let's look at the actor that runs it.
61 |
62 | /// The messages that the pantry actor can receive.
63 | pub type Message {
64 | AddItem(item: String)
65 | // Take item takes a reply subject. Any message that needs to send a reply back
66 | // should take a subject argument for the handler to reply back with.
67 | // Subjects are generic over their message type, and `Result(String, Nil)` is the type
68 | // we want our public `take_item` function to return.
69 | // Really compare this message with the `take_item` function above, and make sure the
70 | // relationship makes sense to you.
71 | TakeItem(reply_with: Subject(Result(String, Nil)), item: String)
72 | ListItems(reply_with: Subject(Set(String)))
73 | Shutdown
74 | }
75 |
76 | // This is our actor's message handler. It's a function that takes a message and the current state of the actor,
77 | // and returns a new state for the actor to continue with.
78 | //
79 | // There's nothing really magic going on under the hood here. An actor is really just a recursive function that
80 | // holds state in its arguments, receives a message, possibly does some work or send messages back to other processes,
81 | // and then calls itself with some new state. The `actor.Next` type is just an abstraction over that pattern.
82 | //
83 | // In fact, take a look at [it's definition](https://hexdocs.pm/gleam_otp/gleam/otp/actor.html#Next)
84 | // and you'll see what I mean.
85 | fn handle_message(
86 | message: Message,
87 | pantry: Set(String),
88 | ) -> actor.Next(Message, Set(String)) {
89 | // We pattern match on the message to decide what to do.
90 | case message {
91 | // This type of message will be in most actors, it's worth just sticking
92 | // at the top.
93 | Shutdown -> actor.Stop(process.Normal)
94 |
95 | // We're adding an item to the pantry. We don't need to reply to anyone, so we just
96 | // send the new state back to the actor to continue with.
97 | AddItem(item) -> actor.continue(set.insert(pantry, item))
98 |
99 | // We're taking an item from the pantry. The `TakeItem` message has a client subject
100 | // for us to send our reply with.
101 | TakeItem(client, item) ->
102 | case set.contains(pantry, item) {
103 | // If the item isn't in the pantry set,
104 | // send back an error to the client and continue with the current state.
105 | False -> {
106 | process.send(client, Error(Nil))
107 | actor.continue(pantry)
108 | }
109 |
110 | // If the item is in the pantry set, send it back to the client,
111 | // and continue with the item removed from the pantry.
112 | True -> {
113 | process.send(client, Ok(item))
114 | actor.continue(set.delete(pantry, item))
115 | }
116 | }
117 |
118 | ListItems(client) -> {
119 | process.send(client, pantry)
120 | actor.continue(pantry)
121 | }
122 | }
123 | }
124 | // That's it! We've implemented a simple pantry actor.
125 | //
126 | // Note: This example is meant to be straightforward. In a real system,
127 | // you probably don't want an actor like this, whose role is to manage a small
128 | // piece of mutable state.
129 | //
130 | // Utilizing processes and actors to bootstrap OOP patterns based on mutable state
131 | // is, well, a bad idea. Remember, all things in moderation. There are times when
132 | // a simple server to hold some mutable state is exactly what you need. But in a
133 | // functional language like Gleam, it shouldn't be your first choice.
134 | //
135 | // There's a lot going on with this example, so don't worry if you need to sit
136 | // with it for a while. When you think you've got it, I recommend heading to the
137 | // supervisors section next.
138 |
--------------------------------------------------------------------------------