├── assets ├── .gitignore ├── test │ └── __mocks__ │ │ └── styleMock.js ├── css │ ├── app │ │ ├── extends │ │ │ ├── _progress_bar.scss │ │ │ └── _tables.scss │ │ ├── utils │ │ │ ├── _resource_usage.scss │ │ │ └── _tabular_info.scss │ │ ├── _liveview.scss │ │ ├── components │ │ │ ├── _fields_card.scss │ │ │ ├── _card.scss │ │ │ ├── _layered_graph.scss │ │ │ ├── _nav_bar.scss │ │ │ ├── _buttons.scss │ │ │ ├── _banner_card.scss │ │ │ ├── _code_field.scss │ │ │ ├── _copy_indicator.scss │ │ │ ├── _app_info.scss │ │ │ ├── _color_bar_legend.scss │ │ │ ├── _logs_card.scss │ │ │ ├── _cookie_status.scss │ │ │ ├── _tabular.scss │ │ │ ├── _hint.scss │ │ │ ├── _modals.scss │ │ │ ├── _backgrounds.scss │ │ │ ├── _color_bar.scss │ │ │ ├── _header.scss │ │ │ ├── _charts.scss │ │ │ └── _footer.scss │ │ ├── _layout.scss │ │ ├── _fonts.scss │ │ ├── _pages.scss │ │ └── _variables.scss │ └── app.scss ├── fonts │ └── live_dashboard_font.woff2 ├── babel.config.js ├── js │ ├── remember_refresh │ │ └── index.js │ ├── request_logger_messages │ │ └── index.js │ ├── metrics_live │ │ ├── color_wheel.js │ │ └── histogram.js │ ├── request_logger_query_parameter │ │ └── index.js │ ├── request_logger_cookie │ │ └── index.js │ ├── color_bar_highlight │ │ └── index.js │ ├── refresh │ │ └── index.js │ └── app.js └── package.json ├── screenshot.png ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── assets.yml │ └── ci.yml ├── test ├── phoenix │ ├── live_dashboard │ │ ├── logger_pubsub_backend_test.exs │ │ ├── layout_view_test.exs │ │ ├── components │ │ │ ├── title_bar_component_test.exs │ │ │ ├── nav_bar_component_test.exs │ │ │ ├── chart_component_test.exs │ │ │ └── layered_graph_component_test.exs │ │ ├── pages │ │ │ ├── os_mon_page_test.exs │ │ │ ├── memory_allocators_page_test.exs │ │ │ ├── request_logger_page_test.exs │ │ │ ├── home_page_test.exs │ │ │ ├── applications_page_test.exs │ │ │ ├── ets_page_test.exs │ │ │ ├── ports_page_test.exs │ │ │ ├── sockets_page_test.exs │ │ │ └── metrics_page_test.exs │ │ ├── request_logger_test.exs │ │ ├── telemetry_listener_test.exs │ │ └── helpers │ │ │ └── helpers_test.exs │ └── live_dashboard_test.exs └── test_helper.exs ├── config └── config.exs ├── traffic.yml ├── guides ├── os_mon.md ├── request_logger.md ├── metrics_history.md ├── ecto_stats.md └── metrics.md ├── .gitignore ├── lib └── phoenix │ ├── live_dashboard.ex │ └── live_dashboard │ ├── components │ ├── title_bar_component.ex │ ├── chart_component.ex │ └── nav_bar_component.ex │ ├── application.ex │ ├── web.ex │ ├── pages │ ├── ets_page.ex │ ├── applications_page.ex │ ├── ports_page.ex │ ├── processes_page.ex │ ├── sockets_page.ex │ └── memory_allocators_page.ex │ ├── layout_view.ex │ ├── controllers │ └── assets.ex │ ├── layouts │ └── dash.html.heex │ ├── logger_pubsub_backend.ex │ ├── info │ ├── modal_component.ex │ ├── socket_info_component.ex │ ├── app_info_component.ex │ ├── port_info_component.ex │ ├── ets_info_component.ex │ └── process_info_component.ex │ ├── request_logger.ex │ ├── telemetry_listener.ex │ └── helpers.ex ├── LICENSE.md ├── mix.exs └── README.md /assets/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | -------------------------------------------------------------------------------- /assets/test/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/styleMock.js 2 | 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phoenixframework/phoenix_live_dashboard/HEAD/screenshot.png -------------------------------------------------------------------------------- /assets/css/app/extends/_progress_bar.scss: -------------------------------------------------------------------------------- 1 | // Bootstrap progress bar 2 | .progress { 3 | border-radius: 0.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /assets/fonts/live_dashboard_font.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phoenixframework/phoenix_live_dashboard/HEAD/assets/fonts/live_dashboard_font.woff2 -------------------------------------------------------------------------------- /assets/css/app/utils/_resource_usage.scss: -------------------------------------------------------------------------------- 1 | // Memory usage visualization 2 | .resource-usage { 3 | &-total { 4 | background-color: $color-gray-100; 5 | color: $color-gray-800; 6 | 7 | &-value { 8 | color: $text-color; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /assets/css/app/_liveview.scss: -------------------------------------------------------------------------------- 1 | /* LiveView specific classes for your customizations */ 2 | .phx-click-loading { 3 | opacity: 0.5; 4 | transition: opacity 1s ease-out; 5 | } 6 | 7 | .phx-disconnected{ 8 | cursor: wait; 9 | } 10 | 11 | .phx-disconnected *{ 12 | pointer-events: none; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/app/components/_fields_card.scss: -------------------------------------------------------------------------------- 1 | .fields-card { 2 | dl { 3 | margin-bottom: 0; 4 | 5 | .code-field { 6 | margin-bottom: 0.25rem; 7 | } 8 | 9 | dd:last-child { 10 | margin-bottom: 0; 11 | 12 | .code-field { 13 | margin-bottom: 0; 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: ["@babel/preset-env"], 4 | env: { 5 | test: { 6 | presets: [ 7 | [ 8 | "@babel/preset-env", 9 | { 10 | targets: { 11 | node: "10" 12 | } 13 | } 14 | ] 15 | ] 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /assets/css/app/components/_card.scss: -------------------------------------------------------------------------------- 1 | // Extra styles on top of basic Bootstrap card 2 | .card { 3 | border-radius: $border-radius; 4 | box-shadow: $box-shadow; 5 | border: none; 6 | } 7 | 8 | .card-title { 9 | margin-bottom: 0.75rem; 10 | 11 | .badge { 12 | font-weight: inherit; 13 | } 14 | } 15 | 16 | .card-usage { 17 | padding-top: 12px; 18 | padding-bottom: 12px; 19 | } -------------------------------------------------------------------------------- /assets/css/app/components/_layered_graph.scss: -------------------------------------------------------------------------------- 1 | .layered-graph { 2 | .connection-line { 3 | stroke: $color-gray; 4 | fill: $color-gray; 5 | stroke-width: 1; 6 | } 7 | 8 | .node-label, .node-detail { 9 | fill: $color-white; 10 | font-family: $font-family-sans-serif; 11 | text-anchor: middle; 12 | } 13 | 14 | .card-body { 15 | overflow-x: auto; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/css/app/components/_nav_bar.scss: -------------------------------------------------------------------------------- 1 | .nav-bar { 2 | background-color: $color-gray; 3 | border-radius: 6px; 4 | line-height: 1.6; 5 | } 6 | 7 | .nav-bar .nav-link { 8 | color: $color-gray-900; 9 | 10 | &:hover { 11 | color: $color-elixir-800; 12 | } 13 | } 14 | 15 | .nav-bar .nav-link.active { 16 | color: $color-elixir-800; 17 | border-bottom: 3px solid $nav-pills-link-active-bg; 18 | } 19 | -------------------------------------------------------------------------------- /assets/js/remember_refresh/index.js: -------------------------------------------------------------------------------- 1 | /** LiveView Hook **/ 2 | 3 | import { storeRefreshData, loadRefreshData } from "../refresh"; 4 | 5 | const PhxRememberRefresh = { 6 | updated() { 7 | let config = loadRefreshData() || {}; 8 | config[this.el.dataset.page] = this.el.value 9 | storeRefreshData(config, this.el.dataset.dashboardMountPath); 10 | } 11 | } 12 | 13 | export default PhxRememberRefresh 14 | -------------------------------------------------------------------------------- /assets/css/app/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | // Extra styles on top of basic Bootstrap buttons 2 | .btn.btn-primary { 3 | background-color: $color-button-primary; 4 | border-width: 0; 5 | 6 | &:not(:disabled):not(.disabled):active { 7 | background-color: darken($color-button-primary, 20%); 8 | } 9 | } 10 | 11 | .btn.btn-secondary { 12 | background-color: $color-gray-warm-700; 13 | border-width: 0; 14 | } 15 | -------------------------------------------------------------------------------- /assets/js/request_logger_messages/index.js: -------------------------------------------------------------------------------- 1 | /** LiveView Hook **/ 2 | 3 | const PhxRequestLoggerMessages = { 4 | updated() { 5 | if (this.el.querySelector('.logger-autoscroll-checkbox').checked) { 6 | const messagesElement = this.el.querySelector('#logger-messages') 7 | messagesElement.scrollTop = messagesElement.scrollHeight 8 | } 9 | } 10 | } 11 | 12 | export default PhxRequestLoggerMessages 13 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | embed_templates: 1, 4 | embed_templates: 2 5 | ] 6 | 7 | [ 8 | import_deps: [:phoenix], 9 | plugins: [Phoenix.LiveView.HTMLFormatter], 10 | # TODO: remove when we drop support for LV 0.19/0.20 11 | migrate_eex_to_curly_interpolation: false, 12 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], 13 | locals_without_parens: locals_without_parens, 14 | export: [locals_without_parens: locals_without_parens] 15 | ] 16 | -------------------------------------------------------------------------------- /assets/css/app/components/_banner_card.scss: -------------------------------------------------------------------------------- 1 | // Small cards with colorful backgrounds 2 | .banner-card { 3 | border-radius: $border-radius; 4 | box-shadow: $box-shadow; 5 | background-color: $white; 6 | padding: 1rem; 7 | } 8 | 9 | .banner-card-title { 10 | margin-bottom: 0.2rem; 11 | } 12 | 13 | .banner-card-value { 14 | font-size: 1.5rem; 15 | font-weight: bold; 16 | margin-bottom: -0.15rem; 17 | } 18 | 19 | #system-info-card .banner-card-value { 20 | font-size: 16px; 21 | font-weight: 400; 22 | padding: 0.3rem; 23 | } 24 | -------------------------------------------------------------------------------- /assets/css/app/components/_code_field.scss: -------------------------------------------------------------------------------- 1 | // Container for code or monospaced text 2 | .code-field { 3 | background-color: $color-gray-100; 4 | color: $color-gray-700; 5 | border-radius: $border-radius; 6 | border-width: 0; 7 | font-weight: 100; 8 | padding: 0.5rem 0.75rem; 9 | white-space: nowrap; 10 | width: 100%; 11 | resize: none; 12 | overflow: auto; 13 | margin-bottom: 0.75rem; 14 | scrollbar-width: none; 15 | 16 | &:focus { 17 | outline: none; 18 | } 19 | 20 | &::-webkit-scrollbar { 21 | display: none; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /assets/css/app/_layout.scss: -------------------------------------------------------------------------------- 1 | // Basic Layout and core styles 2 | html { 3 | height: 100%; 4 | background-color: $color-gray; 5 | } 6 | 7 | body { 8 | background-color: $color-gray; 9 | background: linear-gradient(-15deg, $color-gray, lighten($color-gray, 5%)); 10 | background-size: cover; 11 | background-repeat: repeat-x; 12 | color: $text-color; 13 | font-size: 16px; 14 | line-height: 1.5; 15 | height: 100%; 16 | } 17 | 18 | #main { 19 | padding-top: 3.5rem; 20 | position: relative; 21 | } 22 | 23 | .layout-wrapper { 24 | min-height: 100%; 25 | } 26 | -------------------------------------------------------------------------------- /assets/css/app/components/_copy_indicator.scss: -------------------------------------------------------------------------------- 1 | // Animated text that appears after logger request param code has been copied 2 | .copy-indicator { 3 | opacity: 0; 4 | padding: .375rem .75rem; 5 | display: inline-block; 6 | 7 | &[data-enabled] { 8 | animation-name: blink; 9 | animation-duration: 1.5s; 10 | animation-timing-function: ease-out; 11 | } 12 | } 13 | 14 | @keyframes blink { 15 | 0% { 16 | opacity: 0; 17 | } 18 | 19 | 20% { 20 | opacity: 1; 21 | } 22 | 23 | 60% { 24 | opacity: 1; 25 | } 26 | 27 | 100% { 28 | opacity: 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/css/app/utils/_tabular_info.scss: -------------------------------------------------------------------------------- 1 | .tabular-info { 2 | .tabular-info-not-exists { 3 | border-radius: $border-radius; 4 | color: white; 5 | background-color: $color-gray-700; 6 | padding: 1rem 1.5rem; 7 | } 8 | 9 | .tabular-info-table { 10 | td:empty, pre:empty { 11 | &::after { 12 | font-family: $font-family-sans-serif; 13 | content: "---"; 14 | opacity: 0.5; 15 | font-size: 1rem; 16 | } 17 | } 18 | 19 | td:first-child { 20 | font-weight: 700; 21 | } 22 | 23 | td:last-child { 24 | width: 70%; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Environment 2 | 3 | Make sure you are using the latest LiveView and Dashboard versions before continuing. 4 | 5 | * Elixir version (elixir -v): 6 | * Phoenix version (mix deps): 7 | * Phoenix LiveView version (mix deps): 8 | * Phoenix Dashboard version (mix deps): 9 | * Operating system: 10 | * Browsers you attempted to reproduce this bug on (the more the merrier): 11 | 12 | ### Actual behavior 13 | 14 | 17 | 18 | ### Expected behavior 19 | 20 | -------------------------------------------------------------------------------- /test/phoenix/live_dashboard/logger_pubsub_backend_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.LoggerPubSubBackendTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveDashboardTest.PubSub 5 | require Logger 6 | 7 | @tag :capture_log 8 | test "broadcasts messages when metadata matches" do 9 | Phoenix.PubSub.subscribe(PubSub, "hello:world") 10 | Logger.error("refute_received") 11 | Logger.error("assert_received", logger_pubsub_backend: {PubSub, "hello:world"}) 12 | assert_receive {:logger, :error, msg}, 1000 13 | assert IO.iodata_to_binary(msg) == "[error] assert_received\n" 14 | refute_received {:logger, _, _} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /assets/css/app/components/_app_info.scss: -------------------------------------------------------------------------------- 1 | .app-info { 2 | overflow: auto; 3 | 4 | .node{ 5 | fill: $color-gray-100; 6 | stroke: $color-gray-300; 7 | stroke-width: 1; 8 | } 9 | 10 | .node:hover { 11 | cursor: pointer; 12 | fill: $color-gray-300; 13 | & + .tree-node-text { 14 | fill: change-color($color-gray-900, $lightness: 25%); 15 | } 16 | } 17 | 18 | .line { 19 | stroke: $color-gray-300; 20 | stroke-width: 2 21 | } 22 | 23 | .tree { 24 | position: relative; 25 | margin: 20px 0; 26 | } 27 | 28 | .tree-node-text { 29 | fill: $text-color; 30 | font-size: 14px; 31 | font-family: 'LiveDashboardFont'; 32 | pointer-events: none; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /assets/css/app/_fonts.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Fonts are (c) 2011 by vernon adams (see below). LiveDashboardFont changes are in public domain. 3 | * Copyright (c) 2011 by vernon adams (vern@newtypography.co.uk), 4 | * with Reserved Font Name “Muli”. 5 | * Licensed under SIL Open Font License: https://www.fontsquirrel.com/license/muli 6 | */ 7 | 8 | @import './font_live_dashboard_base64'; 9 | 10 | @font-face { 11 | font-family: 'LiveDashboardFont'; 12 | font-style: normal; 13 | font-display: swap; 14 | src: url("data:font/woff2;base64,#{$live-dashboard-font}") format('woff2'); 15 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 16 | } 17 | -------------------------------------------------------------------------------- /test/phoenix/live_dashboard/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.LayoutViewTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Conn 5 | import Phoenix.ConnTest 6 | alias Phoenix.LiveDashboard.LayoutView 7 | 8 | describe "live_socket_path" do 9 | test "considers script_name" do 10 | conn = put_private(build_conn(), :live_socket_path, "/live") 11 | assert LayoutView.live_socket_path(conn) |> to_string() == "/live" 12 | 13 | conn = %{conn | script_name: ~w(foo bar)} 14 | assert LayoutView.live_socket_path(conn) |> to_string() == "/foo/bar/live" 15 | 16 | conn = put_private(conn, :live_socket_path, "/custom/live") 17 | assert LayoutView.live_socket_path(conn) |> to_string() == "/foo/bar/custom/live" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | config :phoenix, :stacktrace_depth, 20 5 | 6 | config :logger, level: :warning 7 | config :logger, :console, format: "[$level] $message\n" 8 | 9 | if config_env() == :dev do 10 | config :esbuild, 11 | version: "0.14.41", 12 | default: [ 13 | args: 14 | ~w(js/app.js --bundle --minify --sourcemap=external --target=es2020 --outdir=../dist/js), 15 | cd: Path.expand("../assets", __DIR__), 16 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 17 | ] 18 | 19 | config :dart_sass, 20 | version: "1.61.0", 21 | default: [ 22 | args: ~w(--load-path=node_modules --no-source-map css/app.scss ../dist/css/app.css), 23 | cd: Path.expand("../assets", __DIR__) 24 | ] 25 | end 26 | -------------------------------------------------------------------------------- /traffic.yml: -------------------------------------------------------------------------------- 1 | # Simulates traffic for the dashboard 2 | # Link: https://github.com/fcsonline/drill 3 | # $ cargo install drill 4 | # $ /path/to/drill --benchmark traffic.yml --quiet --stats 5 | --- 6 | 7 | concurrency: 20 8 | base: 'http://localhost:4000' 9 | iterations: 1000 10 | 11 | plan: 12 | - name: Get the homepage 13 | request: 14 | url: / 15 | 16 | - name: Get request info 17 | request: 18 | url: /get 19 | 20 | - name: Hello 21 | request: 22 | url: /hello 23 | 24 | - name: Hello by name 25 | request: 26 | url: /hello/{{ item.name }} 27 | shuffle: true 28 | pick: 1 29 | with_items: 30 | - { name: 'mike' } 31 | - { name: 'chris' } 32 | - { name: 'josé' } 33 | 34 | - name: Get the dashboard 35 | request: 36 | url: /dashboard 37 | -------------------------------------------------------------------------------- /guides/os_mon.md: -------------------------------------------------------------------------------- 1 | # Configuring OS Data 2 | 3 | This guide covers how to install and configure your LiveDashboard OS Data. 4 | 5 | ## Enabling `os_mon` 6 | 7 | The OS Data comes from the `os_mon` application, which ships as part of your Erlang distribution. You can start it by adding it to the extra applications section in your `mix.exs`: 8 | 9 | ```elixir 10 | def application do 11 | [ 12 | ..., 13 | extra_applications: [:logger, :runtime_tools, :os_mon] 14 | ] 15 | end 16 | ``` 17 | 18 | > Some operating systems break Erlang into multiple packages. In this case, you may need to install a package such as `erlang-os-mon` or similar. 19 | 20 | ## Configuring os_mon 21 | 22 | See [the Erlang docs](https://www.erlang.org/doc/apps/os_mon/os_mon_app.html) for more information and `os_mon` configuration. 23 | -------------------------------------------------------------------------------- /assets/css/app/components/_color_bar_legend.scss: -------------------------------------------------------------------------------- 1 | // ColorBarLegendComponent 2 | .color-bar-legend { 3 | &-color { 4 | border-radius: 3px; 5 | display: inline-block; 6 | height: 16px; 7 | width: 16px; 8 | } 9 | 10 | &-entry { 11 | user-select: none; 12 | cursor: pointer; 13 | 14 | &[data-muted="true"] { 15 | opacity: 0.15; 16 | } 17 | 18 | &[data-muted="false"] .bg-light-gray { 19 | box-shadow: $bar-shadow; 20 | } 21 | 22 | &:hover { 23 | box-shadow: none; 24 | filter: contrast(120%) brightness(90%); 25 | 26 | &[data-muted="true"] { 27 | opacity: 0.6; 28 | } 29 | } 30 | 31 | &.bg-gradient-light-gray:hover, 32 | &:hover .bg-light-gray { 33 | box-shadow: $bar-shadow; 34 | filter: none; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/js/metrics_live/color_wheel.js: -------------------------------------------------------------------------------- 1 | const COLORS = { 2 | phoenix: [242, 110, 64], 3 | elixir: [75, 68, 115], 4 | red: [255, 99, 132], 5 | orange: [255, 159, 64], 6 | yellow: [255, 205, 86], 7 | green: [75, 192, 192], 8 | blue: [54, 162, 253], 9 | purple: [153, 102, 255], 10 | grey: [201, 203, 207], 11 | } 12 | 13 | const COLOR_NAMES = Object.keys(COLORS) 14 | 15 | export const ColorWheel = { 16 | at: (i) => { 17 | const [r, g, b] = ColorWheel.rgb(i) 18 | return `rgb(${r}, ${g}, ${b})` 19 | }, 20 | rgb: (i) => COLORS[COLOR_NAMES[i % COLOR_NAMES.length]], 21 | } 22 | 23 | export const LineColor = { 24 | at: (i) => { 25 | const [r, g, b] = ColorWheel.rgb(i) 26 | return { 27 | stroke: `rgb(${r}, ${g}, ${b})`, 28 | fill: `rgb(${r}, ${g}, ${b}, 0.1)` 29 | } 30 | } 31 | } 32 | 33 | export default ColorWheel 34 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix_live_dashboard", 3 | "version": "0.1.0", 4 | "description": "The Phoenix LiveDashboard JavaScript client.", 5 | "license": "MIT", 6 | "main": "./assets/js/phoenix_live_dashboard.js", 7 | "repository": {}, 8 | "scripts": { 9 | "test": "jest", 10 | "test.coverage": "jest --coverage", 11 | "test.watch": "jest --watch" 12 | }, 13 | "dependencies": { 14 | "bootstrap": "^4.6.2", 15 | "nprogress": "^0.2.0", 16 | "uplot": "^1.6.22" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.3.4", 20 | "@babel/preset-env": "^7.4.1", 21 | "jest": "^29.0.3", 22 | "jest-environment-jsdom": "^29.0.3" 23 | }, 24 | "jest": { 25 | "testEnvironment": "jsdom", 26 | "testRegex": "/test/.*_test\\.js$", 27 | "moduleNameMapper": { 28 | "\\.(css)$": "/test/__mocks__/styleMock.js" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/phoenix/live_dashboard/components/title_bar_component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.TitleBarComponentTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.LiveViewTest 4 | 5 | alias Phoenix.LiveDashboard.TitleBarComponent 6 | @endpoint Phoenix.LiveDashboardTest.Endpoint 7 | 8 | describe "rendering" do 9 | test "title bar component" do 10 | result = 11 | render_component(TitleBarComponent, 12 | percent: 0.1, 13 | class: "test-class", 14 | csp_nonces: %{style: "style_nonce", script: "script_nonce"}, 15 | dom_id: "title-bar", 16 | inner_block: [%{slot: :__inner_block__, inner_block: fn _, _ -> "123" end}] 17 | ) 18 | 19 | assert result =~ "123" 20 | assert result =~ ~r| 18 |
19 |
27 |
28 |
29 | 30 | 31 | """ 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_, _) do 6 | # Preload it as we check if it is available on remote nodes 7 | Code.ensure_loaded(Phoenix.LiveDashboard.SystemInfo) 8 | 9 | add_logger_backend() 10 | 11 | children = [ 12 | {DynamicSupervisor, name: Phoenix.LiveDashboard.DynamicSupervisor, strategy: :one_for_one} 13 | ] 14 | 15 | Supervisor.start_link(children, strategy: :one_for_one) 16 | end 17 | 18 | if function_exported?(Logger, :default_formatter, 0) do 19 | defp add_logger_backend() do 20 | :ok = 21 | :logger.add_handler( 22 | Phoenix.LiveDashboard.LoggerPubSubBackend, 23 | Phoenix.LiveDashboard.LoggerPubSubBackend, 24 | %{formatter: Logger.default_formatter(colors: [enabled: false])} 25 | ) 26 | end 27 | else 28 | defp add_logger_backend() do 29 | Logger.add_backend(Phoenix.LiveDashboard.LoggerPubSubBackend) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/web.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.Web do 2 | @moduledoc false 3 | 4 | @doc false 5 | def html do 6 | quote do 7 | @moduledoc false 8 | use Phoenix.Component 9 | unquote(view_helpers()) 10 | end 11 | end 12 | 13 | @doc false 14 | def live_view do 15 | quote do 16 | @moduledoc false 17 | use Phoenix.LiveView 18 | unquote(view_helpers()) 19 | end 20 | end 21 | 22 | @doc false 23 | def live_component do 24 | quote do 25 | @moduledoc false 26 | use Phoenix.LiveComponent 27 | unquote(view_helpers()) 28 | end 29 | end 30 | 31 | defp view_helpers do 32 | quote do 33 | import Phoenix.HTML 34 | import Phoenix.HTML.Form 35 | import Phoenix.LiveView.Helpers 36 | import Phoenix.LiveDashboard.Helpers 37 | end 38 | end 39 | 40 | @doc """ 41 | Convenience helper for using the functions above. 42 | """ 43 | defmacro __using__(which) when is_atom(which) do 44 | apply(__MODULE__, which, []) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /assets/js/request_logger_query_parameter/index.js: -------------------------------------------------------------------------------- 1 | /** LiveView Hook **/ 2 | 3 | const copyToClipboard = (textarea) => { 4 | if (!navigator.clipboard){ 5 | // Deprecated clipboard API 6 | textarea.select() 7 | textarea.setSelectionRange(0, 99999) 8 | document.execCommand('copy') 9 | } else { 10 | // Modern Clipboard API 11 | const text = textarea.value 12 | navigator.clipboard.writeText(text) 13 | } 14 | } 15 | 16 | const PhxRequestLoggerQueryParameter = { 17 | mounted() { 18 | this.el.querySelector('.btn-primary').addEventListener('click', e => { 19 | const textarea = this.el.querySelector('textarea') 20 | copyToClipboard(textarea) 21 | const copyIndicator = this.el.querySelector('.copy-indicator') 22 | copyIndicator.setAttribute('data-enabled', 'false') 23 | void copyIndicator.offsetWidth // Resets the animation to ensure it will be played again 24 | copyIndicator.setAttribute('data-enabled', 'true') 25 | }) 26 | } 27 | } 28 | 29 | export default PhxRequestLoggerQueryParameter 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Michael Crumm, Chris McCord, José Valim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /assets/js/request_logger_cookie/index.js: -------------------------------------------------------------------------------- 1 | /** LiveView Hook **/ 2 | 3 | const setCookie = (params) => { 4 | let cookie = `${params.key}=${params.value};path=/` 5 | if (window.location.protocol === "https:") { 6 | cookie += `;samesite=strict` 7 | } 8 | if (params.domain) { 9 | cookie += `;domain=${params.domain}` 10 | } 11 | document.cookie = cookie 12 | } 13 | 14 | const removeCookie = (params) => { 15 | const pastDate = 'Thu, 01 Jan 1970 00:00:00 GMT' 16 | document.cookie = `${params.key}=; expires=${pastDate}` 17 | } 18 | 19 | const isCookieEnabled = (hook) => { 20 | return hook.el.hasAttribute('data-cookie-enabled') 21 | } 22 | 23 | const cookieParams = (hook) => { 24 | return { 25 | key: hook.el.getAttribute('data-cookie-key'), 26 | value: hook.el.getAttribute('data-cookie-value'), 27 | domain: hook.el.getAttribute('data-cookie-domain') 28 | } 29 | } 30 | 31 | const PhxRequestLoggerCookie = { 32 | updated() { 33 | const loggerCookieParams = cookieParams(this) 34 | removeCookie(loggerCookieParams) 35 | 36 | if (isCookieEnabled(this)) { 37 | setCookie(loggerCookieParams) 38 | } 39 | }, 40 | } 41 | 42 | export default PhxRequestLoggerCookie 43 | -------------------------------------------------------------------------------- /assets/js/color_bar_highlight/index.js: -------------------------------------------------------------------------------- 1 | const interactiveItemSelector = '.progress-bar, .color-bar-legend-entry' 2 | let highlightedElementName 3 | 4 | const highlightElements = (containerElement) => { 5 | containerElement.querySelectorAll(interactiveItemSelector).forEach((progressBarElement) => { 6 | if(highlightedElementName) { 7 | const isMuted = progressBarElement.getAttribute('data-name') !== highlightedElementName 8 | 9 | progressBarElement.setAttribute('data-muted', isMuted) 10 | } else { 11 | progressBarElement.removeAttribute('data-muted') 12 | } 13 | }) 14 | } 15 | 16 | const PhxColorBarHighlight = { 17 | mounted() { 18 | this.el.setAttribute('data-highlight-enabled', 'true') 19 | this.el.querySelectorAll(interactiveItemSelector).forEach((progressBarElement) => ( 20 | progressBarElement.addEventListener('click', e => { 21 | const name = e.currentTarget.getAttribute('data-name') 22 | highlightedElementName = name === highlightedElementName ? null : name 23 | highlightElements(this.el) 24 | }) 25 | )) 26 | }, 27 | 28 | updated() { 29 | this.el.setAttribute('data-highlight-enabled', 'true') 30 | highlightElements(this.el) 31 | } 32 | } 33 | 34 | export default PhxColorBarHighlight 35 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @use "nprogress/nprogress.css"; 2 | @use "uplot/dist/uPlot.min.css"; 3 | 4 | @import "./app/variables"; 5 | @import "./app/fonts"; 6 | @import "bootstrap/scss/bootstrap"; 7 | @import "./app/layout"; 8 | 9 | // Extends 10 | @import "./app/extends/progress_bar"; 11 | @import "./app/extends/tables"; 12 | 13 | // Components 14 | @import "./app/components/app_info"; 15 | @import "./app/components/backgrounds"; 16 | @import "./app/components/banner_card"; 17 | @import "./app/components/buttons"; 18 | @import "./app/components/card"; 19 | @import "./app/components/charts"; 20 | @import "./app/components/code_field"; 21 | @import "./app/components/cookie_status"; 22 | @import "./app/components/copy_indicator"; 23 | @import "./app/components/fields_card"; 24 | @import "./app/components/header"; 25 | @import "./app/components/footer"; 26 | @import "./app/components/hint"; 27 | @import "./app/components/logs_card"; 28 | @import "./app/components/tabular"; 29 | @import "./app/components/modals"; 30 | @import "./app/components/nav_bar"; 31 | @import "./app/components/color_bar"; 32 | @import "./app/components/color_bar_legend"; 33 | @import "./app/components/layered_graph"; 34 | 35 | // Utils 36 | @import "./app/utils/resource_usage"; 37 | @import "./app/utils/tabular_info"; 38 | 39 | @import "./app/pages"; 40 | 41 | @import "./app/liveview"; 42 | -------------------------------------------------------------------------------- /assets/css/app/components/_hint.scss: -------------------------------------------------------------------------------- 1 | .hint { 2 | display: inline-block; 3 | position: relative; 4 | 5 | .hint-text { 6 | box-shadow: $box-shadow-darker; 7 | display: none; 8 | } 9 | 10 | .hint-icon { 11 | cursor: help; 12 | height: 15px; 13 | width: 15px; 14 | position: relative; 15 | top: -1px; 16 | } 17 | 18 | &:hover { 19 | .hint-text { 20 | border: 1px solid $color-gray-600; 21 | border-radius: $border-radius; 22 | background-color: $color-gray-100; 23 | color: $color-gray-800; 24 | display: block; 25 | position: absolute; 26 | transform: translateX(-50%); 27 | font-size: 1rem; 28 | bottom: 30px; 29 | left: 18px; 30 | width: 300px; 31 | padding: 1rem 1.5rem; 32 | z-index: 10; 33 | } 34 | } 35 | 36 | .hint-icon-fill { 37 | fill: $color-gray-600; 38 | } 39 | 40 | .hint-icon-stroke { 41 | stroke: $color-gray-600; 42 | } 43 | } 44 | 45 | .card-title .hint { 46 | .hint-icon-fill { 47 | fill: $color-gray-700; 48 | } 49 | 50 | .hint-icon-stroke { 51 | stroke: $color-gray-700; 52 | } 53 | } 54 | 55 | @media (max-width: map-get($grid-breakpoints, sm)) { 56 | .hint{ 57 | &:hover { 58 | .hint-text { 59 | transform: none; 60 | left: 0; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /assets/js/refresh/index.js: -------------------------------------------------------------------------------- 1 | const REFRESH_DATA_COOKIE = "_refresh_data"; 2 | 3 | /** 4 | * Stores refresh data in the `"refresh_data"` cookie. 5 | */ 6 | export function storeRefreshData(refreshData, path) { 7 | const json = JSON.stringify(refreshData); 8 | const encoded = encodeBase64(json); 9 | setCookie(REFRESH_DATA_COOKIE, encoded, path, 157680000); // 5 years 10 | } 11 | 12 | /** 13 | * Loads refresh data from the `"refresh_data"` cookie. 14 | */ 15 | export function loadRefreshData() { 16 | const encoded = getCookieValue(REFRESH_DATA_COOKIE); 17 | if (encoded) { 18 | const json = decodeBase64(encoded); 19 | return JSON.parse(json); 20 | } else { 21 | return null; 22 | } 23 | } 24 | 25 | function getCookieValue(key) { 26 | const cookie = document.cookie 27 | .split("; ") 28 | .find((cookie) => cookie.startsWith(`${key}=`)); 29 | 30 | if (cookie) { 31 | const value = cookie.replace(`${key}=`, ""); 32 | return value; 33 | } else { 34 | return null; 35 | } 36 | } 37 | 38 | function setCookie(key, value, path, maxAge) { 39 | const cookie = `${key}=${value};max-age=${maxAge};path=${path}`; 40 | document.cookie = cookie; 41 | } 42 | 43 | function encodeBase64(string) { 44 | return btoa(unescape(encodeURIComponent(string))); 45 | } 46 | 47 | function decodeBase64(binary) { 48 | return decodeURIComponent(escape(atob(binary))); 49 | } 50 | -------------------------------------------------------------------------------- /assets/css/app/extends/_tables.scss: -------------------------------------------------------------------------------- 1 | tr[phx-click]{ 2 | cursor: pointer; 3 | } 4 | 5 | tr>:first-child { 6 | @extend .pl-4; 7 | } 8 | 9 | table.table-hover tbody tr:hover { 10 | background-color: $color-gray-100; 11 | } 12 | 13 | /* 14 | * `dash-table` adds extra styling on top of regular Bootstrap table css. 15 | * 16 | * `.dash-table-wrapper` can be used to add horizontal scrolling: 17 | * 18 | * ``` 19 | *
20 | * 21 | * ... 22 | *
23 | *
24 | * ``` 25 | * 26 | */ 27 | 28 | .dash-table { 29 | color: $color-gray-900; 30 | margin-bottom: 0; 31 | 32 | th { 33 | background-color: $color-gray-100; 34 | white-space: nowrap; 35 | } 36 | 37 | .dash-table-icon { 38 | padding-left: 0.25rem; 39 | display: inline-block; 40 | } 41 | 42 | .icon-sort { 43 | display: inline-block; 44 | width: 0; 45 | height: 0; 46 | line-height: 6px; 47 | position: relative; 48 | top: -1px; 49 | vertical-align: middle; 50 | } 51 | 52 | .icon-asc { 53 | border-left: 6px solid transparent; 54 | border-right: 6px solid transparent; 55 | border-bottom: 6px solid $color-gray-800; 56 | } 57 | 58 | .icon-desc { 59 | border-left: 6px solid transparent; 60 | border-right: 6px solid transparent; 61 | border-top: 6px solid $color-gray-800; 62 | } 63 | } 64 | 65 | .dash-table-wrapper { 66 | border-radius: $border-radius $border-radius 0 0; 67 | overflow-x: auto; 68 | } 69 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/pages/ets_page.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.EtsPage do 2 | @moduledoc false 3 | use Phoenix.LiveDashboard.PageBuilder 4 | 5 | alias Phoenix.LiveDashboard.SystemInfo 6 | import Phoenix.LiveDashboard.Helpers 7 | 8 | @menu_text "ETS" 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~H""" 13 | <.live_table 14 | id="ets-table" 15 | dom_id="ets-table" 16 | page={@page} 17 | title="ETS" 18 | row_fetcher={&fetch_ets/2} 19 | row_attrs={&row_attrs/1} 20 | rows_name="tables" 21 | > 22 | <:col field={:name} header="Name or module" /> 23 | <:col field={:protection} /> 24 | <:col field={:type} /> 25 | <:col field={:size} text_align="right" sortable={:desc} /> 26 | <:col :let={ets} field={:memory} text_align="right" sortable={:desc}> 27 | <%= format_words(ets[:memory]) %> 28 | 29 | <:col :let={ets} field={:owner}> 30 | <%= encode_pid(ets[:owner]) %> 31 | 32 | 33 | """ 34 | end 35 | 36 | defp fetch_ets(params, node) do 37 | %{search: search, sort_by: sort_by, sort_dir: sort_dir, limit: limit} = params 38 | 39 | SystemInfo.fetch_ets(node, search, sort_by, sort_dir, limit) 40 | end 41 | 42 | defp row_attrs(table) do 43 | [ 44 | {"phx-click", "show_info"}, 45 | {"phx-value-info", encode_ets(table[:id])}, 46 | {"phx-page-loading", true} 47 | ] 48 | end 49 | 50 | @impl true 51 | def menu_link(_, _) do 52 | {:ok, @menu_text} 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /.github/workflows/assets.yml: -------------------------------------------------------------------------------- 1 | name: Assets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "v*.*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | env: 13 | elixir: 1.14.0 14 | otp: 24.3 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: erlef/setup-beam@v1 19 | with: 20 | elixir-version: ${{ env.elixir }} 21 | otp-version: ${{ env.otp }} 22 | 23 | - name: Cache Mix 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | deps 28 | _build 29 | key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-dev 30 | restore-keys: | 31 | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- 32 | 33 | - name: Install Dependencies 34 | run: mix deps.get --only dev 35 | 36 | - name: Setup Node.js 18.x 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 18 40 | 41 | - name: Cache npm dependencies 42 | uses: actions/cache@v4 43 | with: 44 | path: ~/.npm 45 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 46 | restore-keys: | 47 | ${{ runner.os }}-node- 48 | 49 | - name: Install npm dependencies 50 | run: npm ci --prefix assets 51 | 52 | - name: Build assets 53 | run: mix assets.build 54 | 55 | - name: Push updated assets 56 | id: push_assets 57 | uses: stefanzweifel/git-auto-commit-action@v5 58 | with: 59 | commit_message: Update assets 60 | file_pattern: dist 61 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/pages/applications_page.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.ApplicationsPage do 2 | @moduledoc false 3 | use Phoenix.LiveDashboard.PageBuilder 4 | 5 | alias Phoenix.LiveDashboard.SystemInfo 6 | 7 | @menu_text "Applications" 8 | 9 | @impl true 10 | def render(assigns) do 11 | ~H""" 12 | <.live_table 13 | id="apps-table" 14 | dom_id="apps-table" 15 | page={@page} 16 | title="Applications" 17 | row_fetcher={&fetch_applications/2} 18 | row_attrs={&row_attrs/1} 19 | > 20 | <:col field={:name} sortable={:asc} /> 21 | <:col field={:description} /> 22 | <:col field={:state} sortable={:asc} /> 23 | <:col :let={app} field={:tree?} header="Sup tree?" text_align="center"> 24 | <%= if app[:tree?], do: "✓" %> 25 | 26 | <:col field={:version} /> 27 | 28 | """ 29 | end 30 | 31 | defp fetch_applications(params, node) do 32 | %{search: search, sort_by: sort_by, sort_dir: sort_dir, limit: limit} = params 33 | 34 | SystemInfo.fetch_applications(node, search, sort_by, sort_dir, limit) 35 | end 36 | 37 | defp row_attrs(application) do 38 | attrs = [id: "app-#{application[:name]}"] 39 | 40 | cond do 41 | application[:state] == :loaded -> 42 | [{:class, "text-muted"} | attrs] 43 | 44 | application[:tree?] -> 45 | [ 46 | {"phx-click", "show_info"}, 47 | {"phx-value-info", encode_app(application[:name])}, 48 | {"phx-page-loading", true} | attrs 49 | ] 50 | 51 | true -> 52 | attrs 53 | end 54 | end 55 | 56 | @impl true 57 | def menu_link(_, _) do 58 | {:ok, @menu_text} 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.LayoutView do 2 | @moduledoc false 3 | use Phoenix.LiveDashboard.Web, :html 4 | 5 | embed_templates "layouts/*" 6 | 7 | def render("dash.html", assigns), do: dash(assigns) 8 | 9 | defp csp_nonce(conn, type) when type in [:script, :style] do 10 | csp_nonce_assign_key = conn.private.csp_nonce_assign_key[type] 11 | conn.assigns[csp_nonce_assign_key] 12 | end 13 | 14 | def live_socket_path(conn) do 15 | [Enum.map(conn.script_name, &["/" | &1]) | conn.private.live_socket_path] 16 | end 17 | 18 | # TODO: Remove this and the conditional on Phoenix v1.7+ 19 | @compile {:no_warn_undefined, Phoenix.VerifiedRoutes} 20 | 21 | defp asset_path(conn, asset) when asset in [:css, :js] do 22 | hash = Phoenix.LiveDashboard.Assets.current_hash(asset) 23 | 24 | if function_exported?(conn.private.phoenix_router, :__live_dashboard_prefix__, 0) do 25 | prefix = conn.private.phoenix_router.__live_dashboard_prefix__() 26 | 27 | Phoenix.VerifiedRoutes.unverified_path( 28 | conn, 29 | conn.private.phoenix_router, 30 | "#{prefix}/#{asset}-#{hash}" 31 | ) 32 | else 33 | apply( 34 | conn.private.phoenix_router.__helpers__(), 35 | :live_dashboard_asset_path, 36 | [conn, asset, hash] 37 | ) 38 | end 39 | end 40 | 41 | defp custom_head_tags(assigns, key) do 42 | case assigns do 43 | %{^key => components} when is_list(components) -> 44 | assigns = assign(assigns, :components, components) 45 | 46 | ~H""" 47 | <%= for component <- @components do %> 48 | <%= component.(assigns) %> 49 | <% end %> 50 | """ 51 | 52 | _ -> 53 | nil 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/controllers/assets.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.Assets do 2 | # Plug to serve dependency-specific assets for the dashboard. 3 | @moduledoc false 4 | import Plug.Conn 5 | 6 | phoenix_js_paths = 7 | for app <- [:phoenix, :phoenix_html, :phoenix_live_view] do 8 | path = Application.app_dir(app, ["priv", "static", "#{app}.js"]) 9 | Module.put_attribute(__MODULE__, :external_resource, path) 10 | path 11 | end 12 | 13 | css_path = Path.join(__DIR__, "../../../../dist/css/app.css") 14 | @external_resource css_path 15 | @css File.read!(css_path) 16 | 17 | js_path = Path.join(__DIR__, "../../../../dist/js/app.js") 18 | @external_resource js_path 19 | 20 | @js """ 21 | #{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")} 22 | #{File.read!(js_path)} 23 | """ 24 | 25 | @hashes %{ 26 | :css => Base.encode16(:crypto.hash(:md5, @css), case: :lower), 27 | :js => Base.encode16(:crypto.hash(:md5, @js), case: :lower) 28 | } 29 | 30 | def init(asset) when asset in [:css, :js], do: asset 31 | 32 | def call(conn, asset) do 33 | {contents, content_type} = contents_and_type(asset) 34 | 35 | conn 36 | |> put_resp_header("content-type", content_type) 37 | |> put_resp_header("cache-control", "public, max-age=31536000, immutable") 38 | |> put_private(:plug_skip_csrf_protection, true) 39 | |> send_resp(200, contents) 40 | |> halt() 41 | end 42 | 43 | defp contents_and_type(:css), do: {@css, "text/css"} 44 | defp contents_and_type(:js), do: {@js, "text/javascript"} 45 | 46 | @doc """ 47 | Returns the current hash for the given `asset`. 48 | """ 49 | def current_hash(:css), do: @hashes.css 50 | def current_hash(:js), do: @hashes.js 51 | end 52 | -------------------------------------------------------------------------------- /assets/css/app/components/_modals.scss: -------------------------------------------------------------------------------- 1 | .table-hover { 2 | .active { color:#212529; background-color:rgba(0,0,0,.075) } 3 | } 4 | 5 | .dash-modal { 6 | display: block; 7 | overflow-y: auto; 8 | background-color: change-color($color-gray-900, $alpha: 0.5, $lightness: 20%); 9 | 10 | pre { 11 | margin-bottom: 0; 12 | overflow-x: auto; 13 | white-space: pre-wrap; 14 | word-break: break-word; 15 | } 16 | 17 | .modal-dialog { 18 | max-width: 900px; 19 | } 20 | 21 | .modal-fullscreen { 22 | width: 100vw; 23 | max-width: none; 24 | height: 100%; 25 | margin: 0; 26 | 27 | .modal-content { 28 | height: 100%; 29 | border: 0; 30 | border-radius: none; 31 | } 32 | 33 | .modal-header { 34 | border-radius: none; 35 | } 36 | 37 | .modal-body { 38 | overflow-y: auto; 39 | } 40 | } 41 | 42 | .modal-header { 43 | background-color: $color-gray-100; 44 | border-bottom: 2px solid $color-gray-500; 45 | padding: 1rem 1.5rem; 46 | h6 { 47 | font-weight: 700; 48 | } 49 | } 50 | 51 | .modal-content { 52 | .modal-action { 53 | padding: 1rem; 54 | margin: -1rem -1rem -1rem auto; 55 | line-height: 1; 56 | } 57 | 58 | .modal-action-item { 59 | display: inline-block; 60 | color: #aaa; 61 | font-size: 1.5rem; 62 | vertical-align: middle; 63 | font-weight: bold; 64 | text-align: right; 65 | 66 | &:hover, &:focus { 67 | color: black; 68 | text-decoration: none; 69 | cursor: pointer; 70 | } 71 | } 72 | 73 | .modal-action-hidden { 74 | display: none; 75 | } 76 | } 77 | } 78 | 79 | @media (max-width: map-get($grid-breakpoints, lg)) { 80 | .dash-modal { 81 | .modal-dialog { 82 | max-width: 700px; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/layouts/dash.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | <%= custom_head_tags(assigns, :after_opening_head_tag) %> 13 | 14 | 15 | 16 | 17 | <%= assigns[:page_title] || "Phoenix LiveDashboard" %> 18 | 19 | 20 | <%= custom_head_tags(assigns, :before_closing_head_tag) %> 21 | 22 | 23 |
24 |
25 | <%= @inner_content %> 26 |
27 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /lib/phoenix/live_dashboard/pages/ports_page.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboard.PortsPage do 2 | @moduledoc false 3 | use Phoenix.LiveDashboard.PageBuilder 4 | 5 | alias Phoenix.LiveDashboard.SystemInfo 6 | import Phoenix.LiveDashboard.Helpers 7 | 8 | @menu_text "Ports" 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~H""" 13 | <.live_table 14 | id="ports-table" 15 | dom_id="ports-table" 16 | page={@page} 17 | title="Ports" 18 | row_fetcher={&fetch_ports/2} 19 | row_attrs={&row_attrs/1} 20 | > 21 | <:col :let={data} field={:port}> 22 | <%= data[:port] |> encode_port() |> String.trim_leading("Port") %> 23 | 24 | <:col :let={data} field={:name} header="Name or path"> 25 | <%= format_path(data[:name]) %> 26 | 27 | <:col :let={data} field={:os_pid} header="OS pid"> 28 | <%= if data[:os_pid] != :undefined, do: data[:os_pid] %> 29 | 30 | <:col :let={data} field={:input} text_align="right" sortable={:desc}> 31 | <%= format_bytes(data[:input]) %> 32 | 33 | <:col :let={data} field={:output} text_align="right" sortable={:desc}> 34 | <%= format_bytes(data[:output]) %> 35 | 36 | <:col field={:id} text_align="right" /> 37 | <:col :let={data} field={:owner}> 38 | <%= inspect(data[:owner]) %> 39 | 40 | 41 | """ 42 | end 43 | 44 | defp fetch_ports(params, node) do 45 | %{search: search, sort_by: sort_by, sort_dir: sort_dir, limit: limit} = params 46 | 47 | SystemInfo.fetch_ports(node, search, sort_by, sort_dir, limit) 48 | end 49 | 50 | defp row_attrs(port) do 51 | [ 52 | {"phx-click", "show_info"}, 53 | {"phx-value-info", encode_port(port[:port])}, 54 | {"phx-page-loading", true} 55 | ] 56 | end 57 | 58 | @impl true 59 | def menu_link(_, _) do 60 | {:ok, @menu_text} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/phoenix/live_dashboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveDashboardTest do 2 | use ExUnit.Case 3 | 4 | import Plug.Conn 5 | import Phoenix.ConnTest 6 | 7 | @endpoint Phoenix.LiveDashboardTest.Endpoint 8 | 9 | test "embeds phx-socket information" do 10 | assert build_conn() |> get("/dashboard/home") |> html_response(200) =~ 11 | ~s|phx-socket="/live"| 12 | 13 | assert build_conn() |> get("/config/nonode@nohost/home") |> html_response(200) =~ 14 | ~s|phx-socket="/custom/live"| 15 | end 16 | 17 | test "embeds csp nonces" do 18 | html = 19 | build_conn() 20 | |> assign(:script_csp_nonce, "script_nonce") 21 | |> assign(:style_csp_nonce, "style_nonce") 22 | |> get("/dashboard/home") 23 | |> html_response(200) 24 | 25 | refute html =~ "script_nonce" 26 | refute html =~ "style_nonce" 27 | 28 | html = 29 | build_conn() 30 | |> assign(:script_csp_nonce, "script_nonce") 31 | |> assign(:style_csp_nonce, "style_nonce") 32 | |> get("/config/nonode@nohost/home") 33 | |> html_response(200) 34 | 35 | assert html =~ ~s|