├── .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 | [![Package Version](https://img.shields.io/hexpm/v/spectator)](https://hex.pm/packages/spectator) 3 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/spectator/) 4 | 5 | Spectator is a BEAM observer tool written in Gleam, that plays well with gleam_otp processes. 6 | 7 | ![](https://raw.githubusercontent.com/JonasGruenwald/spectator/refs/heads/main/priv/screenshot.png) 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 | 4 | 8 | 12 | 16 | 20 | 24 | 26 | 28 | 29 | 32 | 35 | 36 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------