├── .github └── workflows │ └── release.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── assets ├── met-features.gif └── met.gif ├── go.mod ├── go.sum ├── main.go └── renovate.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | permissions: 2 | contents: write 3 | 4 | name: release 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | - '!v*.*.*-**' 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Unshallow clone 18 | run: git fetch --prune --unshallow 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.23.x' 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v3 25 | with: 26 | args: release --clean 27 | version: latest 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: met 9 | goos: 10 | - darwin 11 | - windows 12 | - linux 13 | goarch: 14 | - amd64 15 | - arm64 16 | ldflags: 17 | - "-X main.Version={{.Version}}" 18 | 19 | archives: 20 | - id: met 21 | format: tar.gz 22 | builds: 23 | - met 24 | name_template: "{{ .Binary }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}" 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - "^docs:" 34 | - "^test:" 35 | 36 | brews: 37 | - name: met 38 | repository: 39 | owner: jaxxstorm 40 | name: homebrew-tap 41 | commit_author: 42 | name: GitHub Actions 43 | email: bot@leebriggs.co.uk 44 | directory: Formula 45 | homepage: "https://leebriggs.co.uk" 46 | description: "Dynamically render prometheus metrics in your terminal." -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lbrlabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Met 2 | 3 | Met is a small CLI tool that will periodically scrape a metrics compatible endpoint and return the values interactively via [Bubbletea](https://github.com/charmbracelet/bubbletea) 4 | 5 | Simply point it at an endpoint, and you'll get a nice periodically refreshed output. 6 | 7 | Counter metrics will accumulate over time, whereas Gauge metrics will show the last returned value. 8 | 9 | ![Met](assets/met-features.gif) 10 | 11 | ## Including and Excluding Metrics 12 | 13 | `met` has flags for controlling the metrics you'd like to display. 14 | 15 | `--include` does a substring match on metric names and _includes_ them. 16 | `--exclude` does a substring match on metric names and _excludes_ them. 17 | `--labels` will examine metric labels and only show the ones with a string match. 18 | 19 | ### Examples 20 | 21 | Given the following metrics 22 | 23 | ``` 24 | # TYPE tailscaled_advertised_routes gauge 25 | # HELP tailscaled_advertised_routes Number of advertised network routes (e.g. by a subnet router) 26 | tailscaled_advertised_routes 0 27 | # TYPE tailscaled_approved_routes gauge 28 | # HELP tailscaled_approved_routes Number of approved network routes (e.g. by a subnet router) 29 | tailscaled_approved_routes 0 30 | # TYPE tailscaled_inbound_bytes_total counter 31 | # HELP tailscaled_inbound_bytes_total Counts the number of bytes received from other peers 32 | tailscaled_inbound_bytes_total{path="derp"} 13972 33 | tailscaled_inbound_bytes_total{path="direct_ipv4"} 13997076 34 | tailscaled_inbound_bytes_total{path="direct_ipv6"} 74484000 35 | # TYPE tailscaled_inbound_dropped_packets_total counter 36 | # HELP tailscaled_inbound_dropped_packets_total Counts the number of dropped packets received by the node from other peers 37 | # TYPE tailscaled_inbound_packets_total counter 38 | # HELP tailscaled_inbound_packets_total Counts the number of packets received from other peers 39 | tailscaled_inbound_packets_total{path="derp"} 101 40 | tailscaled_inbound_packets_total{path="direct_ipv4"} 72229 41 | tailscaled_inbound_packets_total{path="direct_ipv6"} 64962 42 | # TYPE tailscaled_outbound_bytes_total counter 43 | # HELP tailscaled_outbound_bytes_total Counts the number of bytes sent to other peers 44 | tailscaled_outbound_bytes_total{path="derp"} 34988 45 | tailscaled_outbound_bytes_total{path="direct_ipv4"} 9677128 46 | tailscaled_outbound_bytes_total{path="direct_ipv6"} 10987440 47 | # TYPE tailscaled_outbound_dropped_packets_total counter 48 | # HELP tailscaled_outbound_dropped_packets_total Counts the number of packets dropped while being sent to other peers 49 | tailscaled_outbound_dropped_packets_total{reason="error"} 0 50 | # TYPE tailscaled_outbound_packets_total counter 51 | # HELP tailscaled_outbound_packets_total Counts the number of packets sent to other peers 52 | tailscaled_outbound_packets_total{path="derp"} 204 53 | tailscaled_outbound_packets_total{path="direct_ipv4"} 69930 54 | tailscaled_outbound_packets_total{path="direct_ipv6"} 22211 55 | ``` 56 | 57 | #### Include only specific metrics 58 | 59 | ``` 60 | met --endpoint http://100.100.100.100/metrics --include advertised 61 | Prometheus metrics from http://100.100.100.100/metrics (every 2s) 62 | 63 | +----------------------------------+-------+----------+------------+ 64 | | KEY | VALUE | INC DIFF | TOTAL DIFF | 65 | +----------------------------------+-------+----------+------------+ 66 | | > tailscaled_advertised_routes{} | 0.00 | -- | -- | 67 | +----------------------------------+-------+----------+------------+ 68 | 69 | Page 1-1 of 1 total metrics 70 | 71 | 72 | Use ↑/↓ to move selection, PgUp/PgDn to scroll. 73 | Press q or Ctrl+C to quit. 74 | ``` 75 | 76 | #### Exclude metrics 77 | 78 | ``` 79 | met --endpoint http://100.100.100.100/metrics --exclude inbound,outbound 80 | Prometheus metrics from http://100.100.100.100/metrics (every 2s) 81 | 82 | +----------------------------------+-------+----------+------------+ 83 | | KEY | VALUE | INC DIFF | TOTAL DIFF | 84 | +----------------------------------+-------+----------+------------+ 85 | | > tailscaled_advertised_routes{} | 0.00 | -- | -- | 86 | | tailscaled_approved_routes{} | 0.00 | -- | -- | 87 | +----------------------------------+-------+----------+------------+ 88 | 89 | Page 1-2 of 2 total metrics 90 | 91 | 92 | Use ↑/↓ to move selection, PgUp/PgDn to scroll. 93 | Press q or Ctrl+C to quit. 94 | ``` 95 | 96 | #### Labels 97 | 98 | ``` 99 | met --endpoint http://100.100.100.100/metrics --labels path=derp 100 | Prometheus metrics from http://100.100.100.100/metrics (every 2s) 101 | 102 | +--------------------------------------------------+----------+-----------+------------+ 103 | | KEY | VALUE | INC DIFF | TOTAL DIFF | 104 | +--------------------------------------------------+----------+-----------+------------+ 105 | | > tailscaled_inbound_bytes_total{path="derp"} | 13972.00 | +13972.00 | 34988.00 | 106 | | tailscaled_inbound_packets_total{path="derp"} | 101.00 | +101.00 | 204.00 | 107 | | tailscaled_outbound_bytes_total{path="derp"} | 34988.00 | +21016.00 | 34988.00 | 108 | | tailscaled_outbound_packets_total{path="derp"} | 204.00 | +103.00 | 204.00 | 109 | +--------------------------------------------------+----------+-----------+------------+ 110 | ``` 111 | -------------------------------------------------------------------------------- /assets/met-features.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaxxstorm/met/65afbefaee56ea0b7bee892dfbc9888e593090d5/assets/met-features.gif -------------------------------------------------------------------------------- /assets/met.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaxxstorm/met/65afbefaee56ea0b7bee892dfbc9888e593090d5/assets/met.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jaxxstorm/met 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/alecthomas/kong v1.6.1 7 | github.com/charmbracelet/bubbletea v1.2.4 8 | github.com/guptarohit/asciigraph v0.7.3 9 | github.com/olekukonko/tablewriter v0.0.5 10 | github.com/prometheus/client_model v0.6.1 11 | github.com/prometheus/common v0.62.0 12 | ) 13 | 14 | require ( 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/charmbracelet/lipgloss v1.0.0 // indirect 17 | github.com/charmbracelet/x/ansi v0.4.5 // indirect 18 | github.com/charmbracelet/x/term v0.2.1 // indirect 19 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 20 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/mattn/go-localereader v0.0.1 // indirect 23 | github.com/mattn/go-runewidth v0.0.15 // indirect 24 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 25 | github.com/muesli/cancelreader v0.2.2 // indirect 26 | github.com/muesli/termenv v0.15.2 // indirect 27 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 28 | github.com/rivo/uniseg v0.4.7 // indirect 29 | golang.org/x/sync v0.10.0 // indirect 30 | golang.org/x/sys v0.28.0 // indirect 31 | golang.org/x/text v0.21.0 // indirect 32 | google.golang.org/protobuf v1.36.1 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v1.6.1 h1:/7bVimARU3uxPD0hbryPE8qWrS3Oz3kPQoxA/H2NKG8= 4 | github.com/alecthomas/kong v1.6.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= 10 | github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= 11 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 12 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 13 | github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= 14 | github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 15 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 16 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 20 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 21 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 22 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 23 | github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY= 24 | github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= 25 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 26 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 27 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 28 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 29 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 30 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 31 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 32 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 33 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 34 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 35 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 38 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 39 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 40 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 41 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 42 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 43 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 44 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 45 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 49 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 50 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 51 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 52 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 53 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 54 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 55 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 56 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 58 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 59 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 62 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 64 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 65 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 66 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 67 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/alecthomas/kong" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/guptarohit/asciigraph" 16 | "github.com/olekukonko/tablewriter" 17 | dto "github.com/prometheus/client_model/go" 18 | "github.com/prometheus/common/expfmt" 19 | ) 20 | 21 | var Version = "dev" 22 | 23 | type CLI struct { 24 | Endpoint string `help:"Metrics endpoint to poll" short:"e" env:"MET_ENDPOINT"` 25 | Interval time.Duration `help:"Poll interval" default:"2s" short:"s" env:"MET_INTERVAL"` 26 | Version bool `help:"Print version information" short:"v"` 27 | Include []string `help:"Include metrics whose name contains these substrings" short:"i"` 28 | Exclude []string `help:"Exclude metrics whose name contains these substrings" short:"x"` 29 | Labels []string `help:"Show only metrics with label=value (ANDed)" short:"l"` 30 | ShowGraph bool `help:"Display an ASCII graph for the selected metric" default:"false"` 31 | } 32 | 33 | func (c *CLI) AfterApply() error { 34 | if c.Version { 35 | return nil 36 | } 37 | if c.Endpoint == "" { 38 | return errors.New("must specify an endpoint to scrape, e.g. --endpoint http://localhost:9090/metrics") 39 | } 40 | return nil 41 | } 42 | 43 | type metricData struct { 44 | key string 45 | name string 46 | labels string 47 | isCounter bool 48 | prevVal float64 49 | accumVal float64 50 | gaugeVal float64 51 | history []float64 52 | lastDelta float64 53 | lastScrapedVal float64 54 | } 55 | 56 | type labelFilter struct { 57 | name string 58 | value string 59 | } 60 | 61 | type model struct { 62 | endpoint string 63 | interval time.Duration 64 | initialized bool 65 | metricsList []metricData 66 | metricsIndex map[string]int 67 | err error 68 | quit bool 69 | 70 | includes []string 71 | excludes []string 72 | labelFilters []labelFilter 73 | showGraph bool 74 | 75 | selected int 76 | pageStart int 77 | pageSize int 78 | } 79 | 80 | type tickMsg time.Time 81 | type metricsMsg struct { 82 | families map[string]*dto.MetricFamily 83 | err error 84 | } 85 | 86 | const maxHistory = 30 87 | 88 | func (m model) Init() tea.Cmd { 89 | return tea.Batch( 90 | fetchMetricsCmd(m.endpoint), 91 | tickCmd(m.interval), 92 | ) 93 | } 94 | 95 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 96 | switch msg := msg.(type) { 97 | 98 | case tickMsg: 99 | return m, fetchMetricsCmd(m.endpoint) 100 | 101 | case metricsMsg: 102 | if msg.err != nil { 103 | m.err = msg.err 104 | return m, tickCmd(m.interval) 105 | } 106 | newM := updateMetrics(m, msg.families) 107 | if !newM.initialized { 108 | sort.Slice(newM.metricsList, func(i, j int) bool { 109 | if newM.metricsList[i].name == newM.metricsList[j].name { 110 | return newM.metricsList[i].labels < newM.metricsList[j].labels 111 | } 112 | return newM.metricsList[i].name < newM.metricsList[j].name 113 | }) 114 | newM.initialized = true 115 | } 116 | // Make sure selected/pageStart are still valid if the list shrinks 117 | if newM.selected >= len(newM.metricsList) { 118 | newM.selected = len(newM.metricsList) - 1 119 | } 120 | newM.enforcePageBounds() 121 | return newM, tickCmd(newM.interval) 122 | 123 | case tea.KeyMsg: 124 | switch msg.String() { 125 | case "ctrl+c", "q": 126 | m.quit = true 127 | return m, tea.Quit 128 | 129 | case "up", "k": 130 | if m.selected > 0 { 131 | m.selected-- 132 | m.enforcePageBounds() 133 | } 134 | case "down", "j": 135 | if m.selected < len(m.metricsList)-1 { 136 | m.selected++ 137 | m.enforcePageBounds() 138 | } 139 | case "pgup": 140 | m.pageStart -= m.pageSize 141 | if m.pageStart < 0 { 142 | m.pageStart = 0 143 | } 144 | // if selected is now < pageStart, fix that 145 | if m.selected < m.pageStart { 146 | m.selected = m.pageStart 147 | } 148 | case "pgdn": 149 | m.pageStart += m.pageSize 150 | maxStart := len(m.metricsList) - m.pageSize 151 | if maxStart < 0 { 152 | maxStart = 0 153 | } 154 | if m.pageStart > maxStart { 155 | m.pageStart = maxStart 156 | } 157 | // if selected is beyond pageStart+pageSize-1, fix that 158 | pageEnd := m.pageStart + m.pageSize - 1 159 | if m.selected > pageEnd { 160 | m.selected = pageEnd 161 | } 162 | } 163 | } 164 | return m, nil 165 | } 166 | 167 | // Enforce that selected is in [pageStart, pageStart+pageSize-1] 168 | func (m *model) enforcePageBounds() { 169 | pageEnd := m.pageStart + m.pageSize - 1 170 | if m.selected < m.pageStart { 171 | m.pageStart = m.selected 172 | } else if m.selected > pageEnd { 173 | m.pageStart = m.selected - (m.pageSize - 1) 174 | } 175 | if m.pageStart < 0 { 176 | m.pageStart = 0 177 | } 178 | } 179 | 180 | // View 181 | func (m model) View() string { 182 | if m.quit { 183 | return "" 184 | } 185 | if m.err != nil { 186 | return fmt.Sprintf("Error: %v\n\nPress q or Ctrl+C to quit.\n", m.err) 187 | } 188 | if len(m.metricsList) == 0 { 189 | return fmt.Sprintf("Prometheus metrics from %s (every %s)\nNo metrics matched filters or still fetching...\n\nPress q or Ctrl+C to quit.\n", 190 | m.endpoint, m.interval) 191 | } 192 | 193 | tableView := m.renderTablePage() 194 | var graphView string 195 | if m.showGraph { 196 | graphView = m.renderGraph() 197 | } 198 | var sb strings.Builder 199 | sb.WriteString(tableView) 200 | if graphView != "" { 201 | sb.WriteString("\n") 202 | sb.WriteString(graphView) 203 | } 204 | sb.WriteString("\n\nUse ↑/↓ to move selection, PgUp/PgDn to scroll.\nPress q or Ctrl+C to quit.\n") 205 | return sb.String() 206 | } 207 | 208 | // Only render the slice in the current page, plus a table header. 209 | func (m model) renderTablePage() string { 210 | var sb strings.Builder 211 | sb.WriteString(fmt.Sprintf("Prometheus metrics from %s (every %s)\n\n", m.endpoint, m.interval)) 212 | 213 | tableString := &strings.Builder{} 214 | table := tablewriter.NewWriter(tableString) 215 | 216 | table.SetHeader([]string{"Key", "Value", "Delta", "Aggregate"}) 217 | table.SetAutoWrapText(false) 218 | table.SetBorder(true) 219 | table.SetRowSeparator("-") 220 | table.SetColumnSeparator("|") 221 | table.SetCenterSeparator("+") 222 | table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 223 | table.SetAlignment(tablewriter.ALIGN_LEFT) 224 | 225 | // page slice 226 | start := m.pageStart 227 | end := start + m.pageSize 228 | if end > len(m.metricsList) { 229 | end = len(m.metricsList) 230 | } 231 | 232 | for i := start; i < end; i++ { 233 | md := m.metricsList[i] 234 | 235 | cursor := " " 236 | if i == m.selected { 237 | cursor = ">" 238 | } 239 | 240 | valStr := fmt.Sprintf("%.2f", md.lastScrapedVal) 241 | if !md.isCounter { 242 | valStr = fmt.Sprintf("%.2f", md.gaugeVal) 243 | } 244 | incDiffStr := "--" 245 | totalDiffStr := "--" 246 | if md.isCounter { 247 | if md.lastDelta > 0 { 248 | incDiffStr = fmt.Sprintf("\x1b[32m+%.2f\x1b[0m", md.lastDelta) 249 | } else if md.lastDelta < 0 { 250 | incDiffStr = fmt.Sprintf("%.2f", md.lastDelta) 251 | } else { 252 | incDiffStr = "0.00" 253 | } 254 | totalDiffStr = fmt.Sprintf("%.2f", md.accumVal) 255 | } 256 | keyStr := fmt.Sprintf("%s %s", cursor, md.key) 257 | table.Append([]string{keyStr, valStr, incDiffStr, totalDiffStr}) 258 | } 259 | table.Render() 260 | sb.WriteString(tableString.String()) 261 | 262 | // Footer line for pagination 263 | sb.WriteString( 264 | fmt.Sprintf("\nPage %d-%d of %d total metrics\n", 265 | start+1, end, len(m.metricsList)), 266 | ) 267 | return sb.String() 268 | } 269 | 270 | // If "showGraph" is true, show the graph for the selected metric 271 | func (m model) renderGraph() string { 272 | if m.selected < 0 || m.selected >= len(m.metricsList) { 273 | return "" 274 | } 275 | md := m.metricsList[m.selected] 276 | if len(md.history) == 0 { 277 | return "(no data)" 278 | } 279 | title := fmt.Sprintf("%s{%s}", md.name, md.labels) 280 | graph := asciigraph.Plot( 281 | md.history, 282 | asciigraph.Height(12), 283 | asciigraph.Caption(title), 284 | asciigraph.Width(70), 285 | ) 286 | return graph 287 | } 288 | 289 | // Commands 290 | func fetchMetricsCmd(endpoint string) tea.Cmd { 291 | return func() tea.Msg { 292 | fams, err := scrapeMetrics(endpoint) 293 | return metricsMsg{families: fams, err: err} 294 | } 295 | } 296 | 297 | func tickCmd(interval time.Duration) tea.Cmd { 298 | return tea.Tick(interval, func(t time.Time) tea.Msg { 299 | return tickMsg(t) 300 | }) 301 | } 302 | 303 | func scrapeMetrics(url string) (map[string]*dto.MetricFamily, error) { 304 | req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil) 305 | if err != nil { 306 | return nil, err 307 | } 308 | resp, err := http.DefaultClient.Do(req) 309 | if err != nil { 310 | return nil, err 311 | } 312 | defer resp.Body.Close() 313 | if resp.StatusCode != http.StatusOK { 314 | return nil, fmt.Errorf("got status %d from server", resp.StatusCode) 315 | } 316 | var parser expfmt.TextParser 317 | return parser.TextToMetricFamilies(resp.Body) 318 | } 319 | 320 | // Main update logic 321 | func updateMetrics(m model, families map[string]*dto.MetricFamily) model { 322 | if m.metricsIndex == nil { 323 | m.metricsIndex = make(map[string]int) 324 | } 325 | seen := make(map[string]struct{}) 326 | for name, mf := range families { 327 | for _, pm := range mf.Metric { 328 | lblStr, lblKey := renderLabels(pm.Label) 329 | key := name + "{" + lblKey + "}" 330 | 331 | if !m.passNameFilters(name) { 332 | continue 333 | } 334 | if !m.passLabelFilters(pm.Label) { 335 | continue 336 | } 337 | raw := getRawValue(mf, pm) 338 | 339 | idx, found := m.metricsIndex[key] 340 | if !found { 341 | md := metricData{ 342 | key: key, 343 | name: name, 344 | labels: lblStr, 345 | isCounter: mf.GetType() == dto.MetricType_COUNTER, 346 | } 347 | // first time => no big diff 348 | if md.isCounter { 349 | md.prevVal = raw 350 | md.lastScrapedVal = raw 351 | md.lastDelta = 0 352 | } else { 353 | md.gaugeVal = raw 354 | } 355 | m.metricsList = append(m.metricsList, md) 356 | idx = len(m.metricsList) - 1 357 | m.metricsIndex[key] = idx 358 | } 359 | 360 | md := m.metricsList[idx] 361 | if md.isCounter { 362 | diff := raw - md.prevVal 363 | if diff < 0 { 364 | md.accumVal += raw 365 | md.lastDelta = raw 366 | } else if diff > 0 { 367 | md.accumVal += diff 368 | md.lastDelta = diff 369 | } 370 | md.prevVal = raw 371 | md.lastScrapedVal = raw 372 | } else { 373 | md.gaugeVal = raw 374 | md.lastDelta = 0 375 | md.lastScrapedVal = raw 376 | } 377 | 378 | curVal := md.gaugeVal 379 | if md.isCounter { 380 | curVal = md.accumVal 381 | } 382 | md.history = append(md.history, curVal) 383 | if len(md.history) > maxHistory { 384 | md.history = md.history[len(md.history)-maxHistory:] 385 | } 386 | m.metricsList[idx] = md 387 | seen[key] = struct{}{} 388 | } 389 | } 390 | // remove stale metrics 391 | newList := make([]metricData, 0, len(seen)) 392 | newIndex := make(map[string]int, len(seen)) 393 | for _, md := range m.metricsList { 394 | if _, ok := seen[md.key]; ok { 395 | newIndex[md.key] = len(newList) 396 | newList = append(newList, md) 397 | } 398 | } 399 | m.metricsList = newList 400 | m.metricsIndex = newIndex 401 | return m 402 | } 403 | 404 | func getRawValue(mf *dto.MetricFamily, pm *dto.Metric) float64 { 405 | switch mf.GetType() { 406 | case dto.MetricType_COUNTER: 407 | return pm.GetCounter().GetValue() 408 | case dto.MetricType_GAUGE: 409 | return pm.GetGauge().GetValue() 410 | case dto.MetricType_UNTYPED: 411 | return pm.GetUntyped().GetValue() 412 | case dto.MetricType_SUMMARY: 413 | return pm.GetSummary().GetSampleSum() 414 | case dto.MetricType_HISTOGRAM: 415 | return pm.GetHistogram().GetSampleSum() 416 | } 417 | return 0 418 | } 419 | 420 | // Substring-based filters 421 | func (m model) passNameFilters(metricName string) bool { 422 | if len(m.includes) > 0 { 423 | matchedAny := false 424 | for _, inc := range m.includes { 425 | if strings.Contains(metricName, inc) { 426 | matchedAny = true 427 | break 428 | } 429 | } 430 | if !matchedAny { 431 | return false 432 | } 433 | } 434 | for _, exc := range m.excludes { 435 | if strings.Contains(metricName, exc) { 436 | return false 437 | } 438 | } 439 | return true 440 | } 441 | 442 | func (m model) passLabelFilters(lbls []*dto.LabelPair) bool { 443 | if len(m.labelFilters) == 0 { 444 | return true 445 | } 446 | labelMap := make(map[string]string, len(lbls)) 447 | for _, lp := range lbls { 448 | labelMap[lp.GetName()] = lp.GetValue() 449 | } 450 | for _, lf := range m.labelFilters { 451 | val, ok := labelMap[lf.name] 452 | if !ok || val != lf.value { 453 | return false 454 | } 455 | } 456 | return true 457 | } 458 | 459 | func renderLabels(lbls []*dto.LabelPair) (string, string) { 460 | if len(lbls) == 0 { 461 | return "", "" 462 | } 463 | sort.Slice(lbls, func(i, j int) bool { 464 | return lbls[i].GetName() < lbls[j].GetName() 465 | }) 466 | var displayParts, keyParts []string 467 | for _, lp := range lbls { 468 | displayParts = append(displayParts, fmt.Sprintf(`%s="%s"`, lp.GetName(), lp.GetValue())) 469 | keyParts = append(keyParts, fmt.Sprintf(`%s="%s"`, lp.GetName(), lp.GetValue())) 470 | } 471 | return strings.Join(displayParts, " "), strings.Join(keyParts, ",") 472 | } 473 | 474 | func main() { 475 | var cli CLI 476 | kctx := kong.Parse(&cli, 477 | kong.Name("met"), 478 | kong.Description("An interactive terminal-based viewer for Prometheus metrics"), 479 | kong.Vars{"version": Version}, 480 | ) 481 | 482 | if cli.Version { 483 | fmt.Printf("met %s\n", Version) 484 | return 485 | } 486 | 487 | var labelFilters []labelFilter 488 | for _, lf := range cli.Labels { 489 | parts := strings.SplitN(lf, "=", 2) 490 | if len(parts) != 2 { 491 | log.Fatalf("Bad --labels arg %q, want name=value", lf) 492 | } 493 | labelFilters = append(labelFilters, labelFilter{parts[0], parts[1]}) 494 | } 495 | 496 | initialModel := model{ 497 | endpoint: cli.Endpoint, 498 | interval: cli.Interval, 499 | includes: cli.Include, 500 | excludes: cli.Exclude, 501 | labelFilters: labelFilters, 502 | showGraph: cli.ShowGraph, 503 | 504 | // Initialize paging 505 | pageSize: 15, // you can adjust this as needed 506 | pageStart: 0, 507 | selected: 0, 508 | } 509 | 510 | switch kctx.Command() { 511 | default: 512 | p := tea.NewProgram(initialModel) 513 | if _, err := p.Run(); err != nil { 514 | log.Fatal(err) 515 | } 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------