├── .github ├── images │ └── screen.png └── workflows │ └── build.yaml ├── .gitignore ├── LICENSE ├── README.md ├── aur ├── .INSTALL └── PKGBUILD ├── bench ├── bench.json ├── bench.nim ├── benchp.nim └── config.nims ├── src ├── ttop.nim └── ttop │ ├── blog.nim │ ├── config.nim │ ├── format.nim │ ├── limits.nim │ ├── onoff.nim │ ├── procfs.nim │ ├── sys.nim │ ├── triggers.nim │ └── tui.nim └── ttop.nimble /.github/images/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inv2004/ttop/4d46afb4df48b439b9b8e514389223944420ba08/.github/images/screen.png -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | paths-ignore: 10 | - '**/README.md' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | steps: 17 | - run: sudo apt-get -y install musl-tools 18 | - uses: actions/checkout@v4 19 | - uses: jiro4989/setup-nim-action@v2 20 | - run: nim --version && nimble -y -d install && nimble static && nimble staticdebug 21 | - uses: ncipollo/release-action@v1 22 | if: contains(github.ref, 'refs/tags/v') 23 | with: 24 | artifacts: "ttop,ttop-debug" 25 | makeLatest: true 26 | allowUpdates: true 27 | 28 | aur: 29 | runs-on: ubuntu-latest 30 | timeout-minutes: 2 31 | needs: build 32 | # if: contains(github.ref, 'refs/tags/v') 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: ulises-jeremias/github-actions-aur-publish@v1 36 | with: 37 | pkgname: ttop 38 | pkgbuild: aur/PKGBUILD 39 | assets: aur/.INSTALL 40 | commit_username: ${{ secrets.AUR_USERNAME }} 41 | commit_email: ${{ secrets.AUR_EMAIL }} 42 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 43 | commit_message: Update AUR package 44 | ssh_keyscan_types: rsa,ecdsa,ed25519 45 | update_pkgver: true 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /ttop 2 | /ttop-debug 3 | src/ttop.out 4 | lm-sensors 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 inv2004 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 | [![Build](https://github.com/inv2004/ttop/actions/workflows/build.yaml/badge.svg)](https://github.com/inv2004/ttop/actions/workflows/build.yaml) 2 | [![GitHub release (with filter)](https://img.shields.io/github/v/release/inv2004/ttop)](https://github.com/inv2004/ttop/releases/latest) 3 | [![AUR version](https://img.shields.io/aur/version/ttop)](https://aur.archlinux.org/packages/ttop) 4 | [![Github All Releases](https://img.shields.io/github/downloads/inv2004/ttop/total.svg)](https://github.com/inv2004/ttop/releases/latest) 5 | 6 | # ```ttop``` 7 | 8 | System monitoring tool with historical data service, triggers and top-like TUI 9 | 10 | ![image](.github/images/screen.png) 11 | 12 | 13 | - [x] Saving historical snapshots via systemd.timer or crontab 14 | 15 | * It is the main diff from `htop`, `top`, `btop` and etc 16 | 17 | - [x] Scroll via historical data 18 | - [x] External triggers (for notifications or other needs) 19 | - [x] Ascii graph of historical stats (via https://github.com/Yardanico/asciigraph) 20 | 21 | * by default you see full day on the chart, see the moment of the spike and move into it for a detailed analysis 22 | 23 | - [x] TUI with critical values highlights 24 | - [x] Group by program 25 | - [x] Search filters: `@u` - user u, `#d` - docker d 26 | - [x] Temperature via `sysfs` 27 | - [x] User-space only, doesn't require root permissions 28 | - [x] Docker-related info 29 | - [x] Threads tree 30 | - [x] Static build 31 | 32 | ## Install 33 | 34 | ### Arch/AUR 35 | ```bash 36 | yay -S ttop # enables systemd.timers automatically 37 | ``` 38 | 39 | ### Static binary (x86-64) 40 | 41 | ```bash 42 | curl -LO https://github.com/inv2004/ttop/releases/latest/download/ttop \ 43 | && chmod +x ttop 44 | ``` 45 | 46 | ```bash 47 | mv ttop ~/.local/bin/ # add into PATH if necessary 48 | ttop --on # Optional: enable data collector in user's systemd.timers or crontab 49 | ``` 50 | 51 | ### Uninstall 52 | ```bash 53 | ttop --off 54 | rm ~/.local/bin/ttop 55 | ``` 56 | 57 | ### Build from source 58 | ```bash 59 | curl https://nim-lang.org/choosenim/init.sh -sSf | sh # Nim setup from nim-lang.org 60 | ``` 61 | ```bash 62 | git clone https://github.com/inv2004/ttop 63 | cd ttop 64 | nimble -d:release build 65 | ``` 66 | 67 | ### Triggers / Notifications 68 | * stmp support was removed in prev version by the reason that static binary with ssl is more that 3Mb 69 | 70 | From v0.8.1 you can trigger external tool, for example curl, to send notifications 71 | 72 | ![image](https://user-images.githubusercontent.com/4949069/215402008-eb0325f9-3e6e-4908-a6aa-d7b3b64f09db.png) 73 | 74 | ### Config example 75 | `~/.config/ttop/ttop.toml` or `/etc/ttop.toml` 76 | 77 | #### My own server's config 78 | 79 | ```toml 80 | [[trigger]] 81 | cmd = "$HOME/.local/bin/tel.sh" 82 | ``` 83 | 84 | #### Config with all parameters described (if you need it) 85 | 86 | ```toml 87 | # light = false # set true for light term (default = false) 88 | 89 | # refresh_timeout = 1000 # TUI refresh timeout 90 | 91 | # docker = "/var/run/docker.sock" # docker's socket path 92 | 93 | # [data] 94 | # path = "/var/log/ttop" # custom storage path (default = if exists /var/log/ttop, else ~/.cache/ttop ) 95 | 96 | # Trigger is any external script or command which receives text from ttop into stdin + some envs 97 | [[trigger]] # telegram example 98 | on_alert = true # execute trigger on alert (true if no other on_* provided) 99 | on_info = true # execute trigger without alert (default = false) 100 | debug = false # output stdout/err from cmd (default = false) 101 | cmd = ''' 102 | read -d '' TEXT 103 | curl -X POST \ 104 | -H 'Content-Type: application/json' \ 105 | -d "{\"chat_id\": $CHAT_ID, \"text\": \"$TEXT\", \"disable_notification\": $TTOP_INFO}" \ 106 | https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage 107 | ''' 108 | 109 | # cmd receives text from stdin. The following env vars are set: 110 | # TTOP_ALERT (true|false) - if alert 111 | # TTOP_INFO (true|false) - opposite to alert 112 | # TTOP_TYPE (alert|info) - trigger type 113 | # TTOP_HOST - host name 114 | # you can find your CHAT_ID by send smth to your bot and run: 115 | # curl https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates 116 | 117 | [[trigger]] # smtp example 118 | cmd = ''' 119 | read -d '' TEXT 120 | TEXT="Subject: ttop $TTOP_TYPE from $TTOP_HOST 121 | 122 | $TEXT" 123 | echo "$TEXT" | curl --ssl-reqd \ 124 | --url 'smtps://smtp.gmail.com:465' \ 125 | --user 'login:password' \ 126 | --mail-from 'from@gmail.com' \ 127 | --mail-rcpt 'to@gmail.com' \ 128 | --upload-file - 129 | ''' 130 | ``` 131 | -------------------------------------------------------------------------------- /aur/.INSTALL: -------------------------------------------------------------------------------- 1 | 2 | pkgname=ttop 3 | 4 | post_install() { 5 | systemctl daemon-reload 6 | systemctl start "${pkgname}.timer" 7 | } 8 | 9 | pre_remove() { 10 | systemctl stop "${pkgname}.timer" 11 | } 12 | 13 | post_remove() { 14 | systemctl daemon-reload 15 | } 16 | -------------------------------------------------------------------------------- /aur/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=ttop 2 | pkgver=1.5.3 3 | pkgrel=2 4 | pkgdesc="System monitoring tool with historical data service, triggers and top-like TUI" 5 | url="https://github.com/inv2004/ttop" 6 | license=("MIT") 7 | arch=('x86_64') 8 | depends=("glibc") 9 | makedepends=("git" "nim") 10 | source=("git+$url.git#tag=v$pkgver" 11 | ".INSTALL") 12 | sha256sums=('SKIP' 13 | 'SKIP') 14 | install=".INSTALL" 15 | backup=("etc/ttop.toml") 16 | 17 | prepare() { 18 | # Shortcut 19 | echo -e "[Desktop Entry] 20 | Name=ttop 21 | Exec=ttop 22 | Icon=ttop 23 | Terminal=true 24 | Type=Application 25 | Comment=System monitoring tool with historical data service, triggers and top-like TUI" > ttop.desktop 26 | } 27 | 28 | build() { 29 | export NIMBLE_DIR="$srcdir/NIMBLE_CACHE" 30 | cd ttop 31 | nimble -y -d:release build 32 | nim r src/ttop/onoff.nim 33 | } 34 | 35 | package() { 36 | mkdir -p "$pkgdir/usr/lib/systemd/system" "$pkgdir/etc" "$pkgdir/var/log/ttop" 37 | install -Dm644 ttop.desktop -t "$pkgdir/usr/share/applications" 38 | cd ttop 39 | install -Dm644 .github/images/screen.png "$pkgdir/usr/share/pixmaps/ttop.png" 40 | install -Dm644 LICENSE -t "$pkgdir/usr/share/licenses/ttop" 41 | install -Dm644 README.md -t "$pkgdir/usr/share/doc/ttop" 42 | install -Dm644 usr/lib/systemd/system/* "$pkgdir/usr/lib/systemd/system" 43 | install -Dm644 etc/* "$pkgdir/etc" 44 | install -Dm755 ttop -t "$pkgdir/usr/bin" 45 | } 46 | -------------------------------------------------------------------------------- /bench/bench.json: -------------------------------------------------------------------------------- 1 | {"sys":{"datetime":"2023-03-18 02:50:40","hostname":"DESKTOP-H","uptimeHz":828679},"cpu":{"total":9942028,"idle":9897560,"cpu":0.0},"cpus":[{"total":829869,"idle":824838,"cpu":0.0},{"total":828888,"idle":825908,"cpu":0.0},{"total":828309,"idle":822959,"cpu":0.0},{"total":828532,"idle":826419,"cpu":0.0},{"total":828019,"idle":822201,"cpu":0.0},{"total":828475,"idle":826928,"cpu":0.0},{"total":828346,"idle":822571,"cpu":0.0},{"total":828421,"idle":826668,"cpu":0.0},{"total":828173,"idle":822390,"cpu":0.0},{"total":828498,"idle":827296,"cpu":0.0},{"total":827938,"idle":822860,"cpu":0.0},{"total":828548,"idle":826510,"cpu":0.0}],"mem":{"MemTotal":16652541952,"MemFree":14552113152,"MemDiff":1960370176,"MemAvailable":15460610048,"Buffers":654295040,"Cached":429887488,"SwapTotal":4294967296,"SwapFree":4294967296},"pidsInfo":{"1":{"pid":1,"uid":0,"user":"root","name":"init(Ubuntu)","state":"S","vsize":2351104,"rss":1658880,"cpuTime":0,"cpu":0.0,"mem":0.009961722389180142,"cmd":"/init","uptimeHz":828364,"uptime":8283,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[4,7,32,555,13229,13275,13284],"threads":2,"lvl":0,"ord":0},"4":{"pid":4,"uid":0,"user":"root","name":"init","state":"S","vsize":2367488,"rss":69632,"cpuTime":0,"cpu":0.0,"mem":0.0004181463718915122,"cmd":"plan9 --control-socket 5 --log-level 4 --server-fd 6 --pipe-fd 8 --log-truncate","uptimeHz":828318,"uptime":8283,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":2,"lvl":0,"ord":0},"7":{"pid":7,"uid":0,"user":"root","name":"SessionLeader","state":"S","vsize":2355200,"rss":102400,"cpuTime":0,"cpu":0.0,"mem":0.0006149211351345767,"cmd":"/init","uptimeHz":828315,"uptime":8283,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[8],"threads":1,"lvl":0,"ord":0},"8":{"pid":8,"uid":0,"user":"root","name":"Relay(9)","state":"S","vsize":2371584,"rss":106496,"cpuTime":11,"cpu":0.0,"mem":0.0006395179805399598,"cmd":"/init","uptimeHz":828315,"uptime":8283,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[9],"threads":1,"lvl":0,"ord":0},"9":{"pid":9,"uid":1000,"user":"u","name":"fish","state":"S","vsize":244727808,"rss":14188544,"cpuTime":77,"cpu":0.0,"mem":0.08520347248424695,"cmd":"-fish","uptimeHz":828315,"uptime":8283,"ioRead":67469312,"ioWrite":19066880,"ioReadDiff":67469312,"ioWriteDiff":19066880,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":1,"lvl":0,"ord":0},"32":{"pid":32,"uid":0,"user":"root","name":"SessionLeader","state":"S","vsize":2355200,"rss":102400,"cpuTime":0,"cpu":0.0,"mem":0.0006149211351345767,"cmd":"/init","uptimeHz":824951,"uptime":8249,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[33],"threads":1,"lvl":0,"ord":0},"33":{"pid":33,"uid":0,"user":"root","name":"Relay(34)","state":"S","vsize":2371584,"rss":106496,"cpuTime":3,"cpu":0.0,"mem":0.0006395179805399598,"cmd":"/init","uptimeHz":824951,"uptime":8249,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[34],"threads":1,"lvl":0,"ord":0},"34":{"pid":34,"uid":1000,"user":"u","name":"fish","state":"S","vsize":242388992,"rss":11350016,"cpuTime":84,"cpu":0.0,"mem":0.06815785861831648,"cmd":"-fish","uptimeHz":824951,"uptime":8249,"ioRead":456355840,"ioWrite":1515520,"ioReadDiff":456355840,"ioWriteDiff":1515520,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[378],"threads":1,"lvl":0,"ord":0},"378":{"pid":378,"uid":1000,"user":"u","name":"inim","state":"S","vsize":3760128,"rss":2478080,"cpuTime":0,"cpu":0.0,"mem":0.01488109147025676,"cmd":"inim --flags=--cc:tcc --passL:'-ldl -lm'","uptimeHz":800390,"uptime":8003,"ioRead":688128,"ioWrite":1654784,"ioReadDiff":688128,"ioWriteDiff":1654784,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":1,"lvl":0,"ord":0},"555":{"pid":555,"uid":0,"user":"root","name":"Relay(556)","state":"S","vsize":2367488,"rss":110592,"cpuTime":77,"cpu":0.0,"mem":0.0006641148259453429,"cmd":"/init","uptimeHz":797785,"uptime":7977,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":1,"lvl":0,"ord":0},"13229":{"pid":13229,"uid":0,"user":"root","name":"SessionLeader","state":"S","vsize":2355200,"rss":102400,"cpuTime":0,"cpu":0.0,"mem":0.0006149211351345767,"cmd":"/init","uptimeHz":31708,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13230],"threads":1,"lvl":0,"ord":0},"13230":{"pid":13230,"uid":0,"user":"root","name":"Relay(13231)","state":"S","vsize":2371584,"rss":106496,"cpuTime":0,"cpu":0.0,"mem":0.0006395179805399598,"cmd":"/init","uptimeHz":31708,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13231],"threads":1,"lvl":0,"ord":0},"13231":{"pid":13231,"uid":1000,"user":"u","name":"fish","state":"S","vsize":89673728,"rss":6983680,"cpuTime":0,"cpu":0.0,"mem":0.04193762141617813,"cmd":"/bin/fish -c sh -c '\"$VSCODE_WSL_EXT_LOCATION/scripts/wslServer.sh\" ee2b180d582a7f601fa6ecfdad8d9fd269ab1884 stable code-server .vscode-server --host=127.0.0.1 --port=0 --connection-token=2062384549-3090082187-2297417543-136870795 --use-host-proxy --without-browser-env-var --disable-websocket-compression --accept-server-license-terms --telemetry-level=all'","uptimeHz":31708,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13233],"threads":1,"lvl":0,"ord":0},"13233":{"pid":13233,"uid":1000,"user":"u","name":"sh","state":"S","vsize":2678784,"rss":602112,"cpuTime":0,"cpu":0.0,"mem":0.003615736274591311,"cmd":"sh -c \"$VSCODE_WSL_EXT_LOCATION/scripts/wslServer.sh\" ee2b180d582a7f601fa6ecfdad8d9fd269ab1884 stable code-server .vscode-server --host=127.0.0.1 --port=0 --connection-token=2062384549-3090082187-2297417543-136870795 --use-host-proxy --without-browser-env-var --disable-websocket-compression --accept-server-license-terms --telemetry-level=all","uptimeHz":31707,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13234],"threads":1,"lvl":0,"ord":0},"13234":{"pid":13234,"uid":1000,"user":"u","name":"sh","state":"S","vsize":2678784,"rss":544768,"cpuTime":0,"cpu":0.0,"mem":0.003271380438915948,"cmd":"sh /mnt/c/Users/u/.vscode/extensions/ms-vscode-remote.remote-wsl-0.76.1/scripts/wslServer.sh ee2b180d582a7f601fa6ecfdad8d9fd269ab1884 stable code-server .vscode-server --host=127.0.0.1 --port=0 --connection-token=2062384549-3090082187-2297417543-136870795 --use-host-proxy --without-browser-env-var --disable-websocket-compression --accept-server-license-terms --telemetry-level=all","uptimeHz":31707,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13239],"threads":1,"lvl":0,"ord":0},"13239":{"pid":13239,"uid":1000,"user":"u","name":"sh","state":"S","vsize":2678784,"rss":606208,"cpuTime":0,"cpu":0.0,"mem":0.003640333119996694,"cmd":"sh /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/bin/code-server --host=127.0.0.1 --port=0 --connection-token=2062384549-3090082187-2297417543-136870795 --use-host-proxy --without-browser-env-var --disable-websocket-compression --accept-server-license-terms --telemetry-level=all","uptimeHz":31706,"uptime":317,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13243],"threads":1,"lvl":0,"ord":0},"13243":{"pid":13243,"uid":1000,"user":"u","name":"node","state":"S","vsize":964771840,"rss":83476480,"cpuTime":223,"cpu":0.0,"mem":0.501283709361707,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/out/server-main.js --host=127.0.0.1 --port=0 --connection-token=2062384549-3090082187-2297417543-136870795 --use-host-proxy --without-browser-env-var --disable-websocket-compression --accept-server-license-terms --telemetry-level=all","uptimeHz":31706,"uptime":317,"ioRead":16384,"ioWrite":139264,"ioReadDiff":16384,"ioWriteDiff":139264,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13254,13293,13313],"threads":11,"lvl":0,"ord":0},"13254":{"pid":13254,"uid":1000,"user":"u","name":"node","state":"S","vsize":922484736,"rss":57982976,"cpuTime":100,"cpu":0.0,"mem":0.3481929435586027,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/out/bootstrap-fork --type=ptyHost --logsPath /home/u/.vscode-server/data/logs/20230318T024524","uptimeHz":31692,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13357],"threads":12,"lvl":0,"ord":0},"13275":{"pid":13275,"uid":0,"user":"root","name":"SessionLeader","state":"S","vsize":2367488,"rss":102400,"cpuTime":0,"cpu":0.0,"mem":0.0006149211351345767,"cmd":"/init","uptimeHz":31685,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13276],"threads":1,"lvl":0,"ord":0},"13276":{"pid":13276,"uid":0,"user":"root","name":"Relay(13277)","state":"S","vsize":2367488,"rss":110592,"cpuTime":4,"cpu":0.0,"mem":0.0006641148259453429,"cmd":"/init","uptimeHz":31685,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13277],"threads":1,"lvl":0,"ord":0},"13277":{"pid":13277,"uid":1000,"user":"u","name":"node","state":"S","vsize":602161152,"rss":45510656,"cpuTime":28,"cpu":0.0,"mem":0.2732955492992113,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node -e const net = require('net'); process.stdin.pause(); const client = net.createConnection({ host: '127.0.0.1', port: 46727 }, () => { client.pipe(process.stdout); process.stdin.pipe(client); }); client.on('close', function (hadError) { console.error(hadError ? 'Remote close with error' : 'Remote close'); process.exit(hadError ? 1 : 0); }); client.on('error', function (err) { process.stderr.write(err && (err.stack || err.message) || String(err)); });","uptimeHz":31684,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":7,"lvl":0,"ord":0},"13284":{"pid":13284,"uid":0,"user":"root","name":"SessionLeader","state":"S","vsize":2367488,"rss":102400,"cpuTime":0,"cpu":0.0,"mem":0.0006149211351345767,"cmd":"/init","uptimeHz":31674,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13285],"threads":1,"lvl":0,"ord":0},"13285":{"pid":13285,"uid":0,"user":"root","name":"Relay(13286)","state":"S","vsize":2367488,"rss":110592,"cpuTime":9,"cpu":0.0,"mem":0.0006641148259453429,"cmd":"/init","uptimeHz":31674,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13286],"threads":1,"lvl":0,"ord":0},"13286":{"pid":13286,"uid":1000,"user":"u","name":"node","state":"S","vsize":599363584,"rss":41066496,"cpuTime":47,"cpu":0.0,"mem":0.2466079720343707,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node -e const net = require('net'); process.stdin.pause(); const client = net.createConnection({ host: '127.0.0.1', port: 46727 }, () => { client.pipe(process.stdout); process.stdin.pipe(client); }); client.on('close', function (hadError) { console.error(hadError ? 'Remote close with error' : 'Remote close'); process.exit(hadError ? 1 : 0); }); client.on('error', function (err) { process.stderr.write(err && (err.stack || err.message) || String(err)); });","uptimeHz":31674,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":7,"lvl":0,"ord":0},"13293":{"pid":13293,"uid":1000,"user":"u","name":"node","state":"S","vsize":1035284480,"rss":185765888,"cpuTime":549,"cpu":0.0,"mem":1.115540729670338,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/out/bootstrap-fork --type=extensionHost --transformURIs --useHostProxy=true","uptimeHz":31657,"uptime":316,"ioRead":421888,"ioWrite":1413120,"ioReadDiff":421888,"ioWriteDiff":1413120,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13385,13499,13524],"threads":12,"lvl":0,"ord":0},"13313":{"pid":13313,"uid":1000,"user":"u","name":"node","state":"S","vsize":855605248,"rss":44204032,"cpuTime":17,"cpu":0.0,"mem":0.2654491556148941,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/out/bootstrap-fork --type=fileWatcher","uptimeHz":31627,"uptime":316,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":13,"lvl":0,"ord":0},"13357":{"pid":13357,"uid":1000,"user":"u","name":"fish","state":"S","vsize":166907904,"rss":9293824,"cpuTime":41,"cpu":0.0,"mem":0.05581024222481418,"cmd":"/bin/fish","uptimeHz":31584,"uptime":315,"ioRead":19779584,"ioWrite":20123648,"ioReadDiff":19779584,"ioWriteDiff":20123648,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[15261],"threads":3,"lvl":0,"ord":0},"13385":{"pid":13385,"uid":1000,"user":"u","name":"node","state":"S","vsize":604196864,"rss":40800256,"cpuTime":9,"cpu":0.0,"mem":0.2450091770830208,"cmd":"/home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/node /home/u/.vscode-server/bin/ee2b180d582a7f601fa6ecfdad8d9fd269ab1884/extensions/json-language-features/server/dist/node/jsonServerMain --node-ipc --clientProcessId=13293","uptimeHz":31525,"uptime":315,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":7,"lvl":0,"ord":0},"13499":{"pid":13499,"uid":1000,"user":"u","name":"cpptools","state":"S","vsize":1240498176,"rss":17121280,"cpuTime":39,"cpu":0.0,"mem":0.1028148137945012,"cmd":"/home/u/.vscode-server/extensions/ms-vscode.cpptools-1.14.4-linux-x64/bin/cpptools","uptimeHz":27704,"uptime":277,"ioRead":2072576,"ioWrite":376832,"ioReadDiff":2072576,"ioWriteDiff":376832,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":17,"lvl":0,"ord":0},"13524":{"pid":13524,"uid":1000,"user":"u","name":"nimsuggest","state":"S","vsize":5177344,"rss":2256896,"cpuTime":0,"cpu":0.0,"mem":0.01355286181836607,"cmd":"/home/u/.nimble/bin/nimsuggest --epc --v2 src/ttop/blog.nim","uptimeHz":27695,"uptime":276,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[13525],"threads":1,"lvl":0,"ord":0},"13525":{"pid":13525,"uid":1000,"user":"u","name":"nimsuggest","state":"S","vsize":176807936,"rss":163835904,"cpuTime":118,"cpu":0.0,"mem":0.9838492193699173,"cmd":"/home/u/.choosenim/toolchains/nim-1.6.12/bin/nimsuggest --epc --v2 src/ttop/blog.nim","uptimeHz":27695,"uptime":276,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":2,"lvl":0,"ord":0},"15261":{"pid":15261,"uid":1000,"user":"u","name":"ttop","state":"R","vsize":10194944,"rss":8306688,"cpuTime":1,"cpu":0.0,"mem":0.04988240248211687,"cmd":"./ttop -s","uptimeHz":1,"uptime":0,"ioRead":0,"ioWrite":0,"ioReadDiff":0,"ioWriteDiff":0,"netIn":0,"netOut":0,"netInDiff":0,"netOutDiff":0,"children":[],"threads":1,"lvl":0,"ord":0}},"disk":{"/dev/sdc":{"avail":1012532379648,"total":1081101176832,"io":0,"ioRead":0,"ioWrite":0,"ioUsage":0,"ioUsageRead":0,"ioUsageWrite":0,"path":"/mnt/wslg/distro"}},"net":{"lo":{"netIn":102910842,"netInDiff":0,"netOut":102910842,"netOutDiff":0},"bond0":{"netIn":0,"netInDiff":0,"netOut":0,"netOutDiff":0},"dummy0":{"netIn":0,"netInDiff":0,"netOut":0,"netOutDiff":0},"tunl0":{"netIn":0,"netInDiff":0,"netOut":0,"netOutDiff":0},"sit0":{"netIn":0,"netInDiff":0,"netOut":0,"netOutDiff":0},"eth0":{"netIn":566426,"netInDiff":0,"netOut":177207,"netOutDiff":0}},"temp":{"cpu":null,"nvme":null}} -------------------------------------------------------------------------------- /bench/bench.nim: -------------------------------------------------------------------------------- 1 | import ttop/procfs 2 | 3 | import criterion 4 | import jsony 5 | import marshal 6 | import json 7 | import times 8 | import tables 9 | import zippy 10 | 11 | proc dumpHook*(s: var string, v: DateTime) = 12 | s.add '"' & v.format("yyyy-MM-dd hh:mm:ss") & '"' 13 | 14 | proc parseHook*(s: string, i: var int, v: var DateTime) = 15 | var str: string 16 | parseHook(s, i, str) 17 | v = parse(str, "yyyy-MM-dd hh:mm:ss") 18 | 19 | let str1 = readFile("bench/bench.json") 20 | let info = str1.fromJson(FullInfo) 21 | let str2 = $$info 22 | let str3 = compress(str2) 23 | 24 | var cfg = newDefaultConfig() 25 | cfg.budget = 1.0 26 | cfg.minSamples = 10 27 | 28 | benchmark cfg: 29 | # proc jsonySer() {.measure.} = 30 | # doAssert jsony.toJson(info).len == 16611 31 | 32 | # proc jsonyDeser() {.measure.} = 33 | # doAssert str1.fromJson(FullInfo).pidsInfo.len == 32 34 | 35 | proc jsonSer() {.measure.} = 36 | doAssert (%info).len == 16611 37 | 38 | proc marshall() {.measure.} = 39 | doAssert ($$info).len == 51298 40 | 41 | proc unmarshall() {.measure.} = 42 | doAssert to[FullInfo](str2).pidsInfo.len == 32 43 | 44 | proc uncompress() {.measure.} = 45 | doAssert uncompress(str3).len == 51298 46 | -------------------------------------------------------------------------------- /bench/benchp.nim: -------------------------------------------------------------------------------- 1 | import ttop/procfs 2 | 3 | import tables 4 | import criterion 5 | 6 | var cfg = newDefaultConfig() 7 | cfg.budget = 1.0 8 | cfg.minSamples = 10 9 | 10 | let fi = fullInfo() 11 | 12 | benchmark cfg: 13 | proc collectFs() {.measure.} = 14 | assert fullInfo().pidsInfo.len > 5 15 | 16 | proc sortByChildren() {.measure.} = 17 | fi.sort(Pid, true) 18 | assert fi.pidsInfo.len > 5 19 | -------------------------------------------------------------------------------- /bench/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../src") -------------------------------------------------------------------------------- /src/ttop.nim: -------------------------------------------------------------------------------- 1 | import ttop/tui 2 | import ttop/blog 3 | import ttop/onoff 4 | import ttop/triggers 5 | 6 | import strutils 7 | import os 8 | 9 | const NimblePkgVersion {.strdefine.} = "Unknown" 10 | 11 | const Help = """ 12 | ttop - system monitoring tool with TUI and historical data service 13 | 14 | Usage: 15 | run [optional-param] 16 | Options: 17 | -h, --help print this help 18 | -s, --save save snapshot 19 | --on enable system.timer (or cron) collector every 10 minutes 20 | --on enable system.timer (or cron) collector every minutes 21 | --off disable collector 22 | -v, --version version number (""" & NimblePkgVersion & """) 23 | 24 | """ 25 | 26 | proc main() = 27 | try: 28 | case paramCount(): 29 | of 0: 30 | tui() 31 | of 1: 32 | case paramStr(1) 33 | of "-h", "--help": 34 | echo Help 35 | of "-s", "--save": 36 | smtpSave save() 37 | of "--on": 38 | onoff(true) 39 | of "--off": 40 | onoff(false) 41 | of "-v", "--version": 42 | echo NimblePkgVersion 43 | else: 44 | when defined(debug): 45 | if paramStr(1) == "--colors": 46 | colors() 47 | echo Help 48 | quit 1 49 | of 2: 50 | if paramStr(1) == "--on": 51 | onoff(true, parseUInt(paramStr(2))) 52 | else: 53 | echo Help 54 | quit 1 55 | else: 56 | echo Help 57 | quit 1 58 | except CatchableError, Defect: 59 | let ex = getCurrentException() 60 | echo ex.msg 61 | echo ex.getStackTrace() 62 | quit 1 63 | 64 | when isMainModule: 65 | main() 66 | 67 | -------------------------------------------------------------------------------- /src/ttop/blog.nim: -------------------------------------------------------------------------------- 1 | import procfs 2 | import config 3 | 4 | import marshal 5 | import zippy 6 | import streams 7 | import tables 8 | import times 9 | import os 10 | import sequtils 11 | import algorithm 12 | import jsony 13 | 14 | type StatV1* = object 15 | prc*: int 16 | cpu*: float 17 | mem*: uint 18 | io*: uint 19 | 20 | type StatV2* = object 21 | prc*: int 22 | cpu*: float 23 | memTotal*: uint 24 | memAvailable*: uint 25 | io*: uint 26 | 27 | proc toStatV2(a: StatV1): StatV2 = 28 | result.prc = a.prc 29 | result.cpu = a.cpu 30 | result.io = a.io 31 | 32 | const TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:sszzz" 33 | 34 | proc dumpHook*(s: var string, v: DateTime) = 35 | s.add '"' & v.format(TIME_FORMAT) & '"' 36 | 37 | proc parseHook*(s: string, i: var int, v: var DateTime) = 38 | var str: string 39 | parseHook(s, i, str) 40 | v = parse(str, TIME_FORMAT) 41 | 42 | proc flock(fd: FileHandle, op: int): int {.header: "", 43 | importc: "flock".} 44 | 45 | proc genStat(f: FullInfoRef): StatV2 = 46 | var io: uint = 0 47 | for _, disk in f.disk: 48 | io += disk.ioUsageRead + disk.ioUsageWrite 49 | 50 | StatV2( 51 | prc: f.pidsInfo.len, 52 | cpu: f.cpu.cpu, 53 | memTotal: f.mem.MemTotal, 54 | memAvailable: f.mem.MemAvailable, 55 | io: io 56 | ) 57 | 58 | proc saveStat*(s: FileStream, f: FullInfoRef) = 59 | var stat = genStat(f) 60 | 61 | let sz = sizeof(StatV2) 62 | s.write sz.uint32 63 | s.writeData stat.addr, sz 64 | 65 | proc stat(s: FileStream): StatV2 = 66 | let sz = s.readUInt32().int 67 | var rsz: int 68 | case sz 69 | of sizeof(StatV2): 70 | rsz = s.readData(result.addr, sizeof(StatV2)) 71 | doAssert sz == rsz 72 | of sizeof(StatV1): 73 | var sv1: StatV1 74 | rsz = s.readData(sv1.addr, sizeof(StatV1)) 75 | doAssert sz == rsz 76 | result = toStatV2 sv1 77 | else: 78 | discard 79 | 80 | proc infoFromGzip(buf: string): FullInfo = 81 | let jsonStr = uncompress(buf) 82 | try: 83 | return jsonStr.fromJson(FullInfo) 84 | except JsonError: 85 | return to[FullInfo](jsonStr) 86 | 87 | proc hist*(ii: int, blog: string, live: var seq[StatV2], forceLive: bool): (FullInfoRef, seq[StatV2]) = 88 | let fi = fullInfo() 89 | if ii == 0 or forceLive: 90 | result[0] = fi 91 | live.add genStat(fi) 92 | 93 | live.delete((0..live.high - 1000)) 94 | 95 | let s = newFileStream(blog) 96 | if s == nil: 97 | return 98 | defer: s.close() 99 | 100 | var buf = "" 101 | 102 | while not s.atEnd(): 103 | result[1].add s.stat() 104 | let sz = s.readUInt32().int 105 | buf = s.readStr(sz) 106 | discard s.readUInt32() 107 | if not forceLive and ii == result[1].len: 108 | new(result[0]) 109 | result[0][] = infoFromGzip(buf) 110 | 111 | if ii == -1: 112 | if result[1].len > 0: 113 | new(result[0]) 114 | result[0][] = infoFromGzip(buf) 115 | 116 | else: 117 | result[0] = fullInfo() 118 | 119 | proc histNoLive*(ii: int, blog: string): (FullInfoRef, seq[StatV2]) = 120 | var live = newSeq[StatV2]() 121 | hist(ii, blog, live, false) 122 | 123 | proc saveBlog(): string = 124 | let dir = getCfg().path 125 | if not dirExists(dir): 126 | createDir(dir) 127 | os.joinPath(dir, now().format("yyyy-MM-dd")).addFileExt("blog") 128 | 129 | proc moveBlog*(d: int, b: string, hist, cnt: int): (string, int) = 130 | if d < 0 and hist == 0 and cnt > 0: 131 | return (b, cnt) 132 | elif d < 0 and hist > 1: 133 | return (b, hist-1) 134 | elif d > 0 and hist > 0 and hist < cnt: 135 | return (b, hist+1) 136 | let dir = getCfg().path 137 | let files = sorted toSeq(walkFiles(os.joinPath(dir, "*.blog"))) 138 | if d == 0 or b == "": 139 | if files.len > 0: 140 | return (files[^1], 0) 141 | else: 142 | return ("", 0) 143 | else: 144 | let idx = files.find(b) 145 | if d < 0: 146 | if idx > 0: 147 | return (files[idx-1], histNoLive(-1, files[idx-1])[1].len) 148 | else: 149 | return (b, 1) 150 | elif d > 0: 151 | if idx < files.high: 152 | return (files[idx+1], 1) 153 | else: 154 | return (files[^1], 0) 155 | else: 156 | doAssert false 157 | 158 | proc save*(): FullInfoRef = 159 | var lastBlog = moveBlog(0, "", 0, 0)[0] 160 | var (prev, _) = histNoLive(-1, lastBlog) 161 | result = if prev == nil: fullInfo() else: fullInfo(prev) 162 | let buf = compress(result[].toJson()) 163 | let blog = saveBlog() 164 | let file = open(blog, fmAppend) 165 | defer: file.close() 166 | if flock(file.getFileHandle, 2 or 4) != 0: 167 | writeLine(stderr, "cannot open locked: " & blog) 168 | quit 1 169 | defer: discard flock(file.getFileHandle, 8) 170 | let s = newFileStream(file) 171 | if s == nil: 172 | raise newException(IOError, "cannot open " & blog) 173 | 174 | s.saveStat result 175 | s.write buf.len.uint32 176 | s.write buf 177 | s.write buf.len.uint32 178 | 179 | when isMainModule: 180 | var (blog, h) = moveBlog(0, "", 0, 0) 181 | var live = newSeq[StatV2]() 182 | var (info, stats) = hist(h, blog, live) 183 | echo info.toJson 184 | -------------------------------------------------------------------------------- /src/ttop/config.nim: -------------------------------------------------------------------------------- 1 | import parsetoml 2 | import os 3 | 4 | const cfgName = "ttop.toml" 5 | 6 | const PKGDATA* = "/var/log/ttop" 7 | const DOCKER_SOCK* = "/var/run/docker.sock" 8 | 9 | type 10 | Trigger* = object 11 | onAlert*: bool 12 | onInfo*: bool 13 | debug*: bool 14 | cmd*: string 15 | CfgRef* = ref object 16 | path*: string 17 | docker*: string 18 | light*: bool 19 | triggers*: seq[Trigger] 20 | refreshTimeout*: int 21 | 22 | var cfg: CfgRef 23 | 24 | proc getDataDir(): string = 25 | if dirExists PKGDATA: 26 | return PKGDATA 27 | else: 28 | getCacheDir("ttop") 29 | 30 | proc loadConfig(): TomlValueRef = 31 | try: 32 | return parseFile(getConfigDir() / "ttop" / cfgName) 33 | except IOError: 34 | try: 35 | return parseFile("/etc" / cfgName) 36 | except IOError: 37 | discard 38 | 39 | proc initCfg*() = 40 | let toml = loadConfig() 41 | 42 | cfg = CfgRef( 43 | light: toml{"light"}.getBool(), 44 | path: toml{"data", "path"}.getStr(getDataDir()), 45 | docker: toml{"docker"}.getStr(DOCKER_SOCK), 46 | refreshTimeout: toml{"refresh_timeout"}.getInt(1000) 47 | ) 48 | 49 | for t in toml{"trigger"}.getElems(): 50 | let onInfo = t{"on_info"}.getBool() 51 | let onAlert = t{"on_alert"}.getBool(not onInfo) 52 | cfg.triggers.add Trigger( 53 | onAlert: onAlert, 54 | onInfo: onInfo, 55 | debug: t{"debug"}.getBool(), 56 | cmd: t{"cmd"}.getStr() 57 | ) 58 | 59 | proc getCfg*(): CfgRef = 60 | if cfg == nil: 61 | initCfg() 62 | cfg 63 | 64 | -------------------------------------------------------------------------------- /src/ttop/format.nim: -------------------------------------------------------------------------------- 1 | import strformat 2 | import times 3 | 4 | proc formatP*(f: float, left = false): string = 5 | if f >= 100.0: 6 | fmt"{f:.0f}" 7 | elif left: 8 | fmt"{f:<4.1f}" 9 | else: 10 | fmt"{f:4.1f}" 11 | 12 | proc formatSPair*(b: int): (float, string) = 13 | const postStr = [" b", "KB", "MB", "GB", "TB", "PB"] 14 | 15 | var x = b * 10 16 | for i, v in postStr: 17 | if x < 10240: 18 | return (x / 10, v) 19 | x = (x+512) div 1024 20 | 21 | return (b.float, ".") 22 | 23 | proc formatN3*(a: int): string = 24 | if a > 999: 25 | fmt "{(a div 1000):2}k" 26 | else: 27 | fmt "{a:3}" 28 | 29 | proc formatS*(a: int): string = 30 | let (n, s) = formatSPair(a) 31 | if a < 1024: 32 | fmt "{n.int} {s}" 33 | else: 34 | fmt "{n:.1f} {s}" 35 | 36 | proc formatS*(a, b: int, delim = " / "): string = 37 | let (n1, s1) = formatSPair(a) 38 | let (n2, s2) = formatSPair(b) 39 | if s1 == s2: 40 | if b < 1024: 41 | fmt "{n1.int}{delim}{n2.int} {s2}" 42 | else: 43 | fmt "{n1:.1f}{delim}{n2:.1f} {s2}" 44 | else: 45 | if a < 1024 and b < 1024: 46 | fmt "{n1.int} {s1}{delim}{n2.int} {s2}" 47 | elif a < 1024: 48 | fmt "{n1.int} {s1}{delim}{n2:.1f} {s2}" 49 | else: 50 | fmt "{n1:.1f} {s1}{delim}{n2.int} {s2}" 51 | 52 | proc formatSI*(a, b: int, delim = "/"): string = 53 | let (n1, s1) = formatSPair(a) 54 | let (n2, s2) = formatSPair(b) 55 | if s1 == s2: 56 | fmt "{n1.int}{delim}{n2.int}{s2[0]}" 57 | else: 58 | fmt "{n1.int}{s1[0]}{delim}{n2.int}{s2[0]}" 59 | 60 | proc formatS*(a: uint): string = 61 | formatS(int(a)) 62 | 63 | proc formatS*(a, b: uint, delim = " / "): string = 64 | formatS(int(a), int(b), delim) 65 | 66 | proc formatD*(a, b: uint, delim = " / "): string = 67 | formatS(int(b-a), int(b), delim) 68 | 69 | proc formatSI*(a, b: uint, delim = "/"): string = 70 | formatSI(int(a), int(b), delim) 71 | 72 | proc formatT*(ts: int): string = 73 | let d = initDuration(seconds = ts) 74 | let p = d.toParts() 75 | fmt"{p[Days]*24 + p[Hours]:2}:{p[Minutes]:02}:{p[Seconds]:02}" 76 | 77 | proc formatT*(ts: uint): string = 78 | formatT(int(ts)) 79 | 80 | proc formatC*(temp: float64): string = 81 | fmt"{temp.int}℃" 82 | 83 | when isMainModule: 84 | echo "|", 0.0.formatP, "|" 85 | echo "|", 5.2.formatP, "|" 86 | echo "|", 10.5.formatP, "|" 87 | echo "|", 100.formatP, "|" 88 | echo "|", 512.formatS, "|" 89 | echo "|", 1512.formatS, "|" 90 | echo "|", 8512.formatS, "|" 91 | echo "|", 80512.formatS, "|" 92 | echo "|", 2000512.formatS, "|" 93 | echo "|", 20000512.formatS, "|" 94 | echo "|", 200000512.formatS, "|" 95 | echo "|", 2000000512.formatS, "|" 96 | echo "|", 20000000512.formatS, "|" 97 | echo "|", 200000000512.formatS, "|" 98 | echo "|", 2000000000512.formatS, "|" 99 | echo "|", formatS(3156216320.uint, 12400328704.uint), "|" 100 | echo "|", formatS(156216320.uint, 12400328704.uint), "|" 101 | echo "|", formatS(320.uint, 12400328704.uint), "|" 102 | -------------------------------------------------------------------------------- /src/ttop/limits.nim: -------------------------------------------------------------------------------- 1 | import procfs 2 | 3 | import options 4 | import tables 5 | 6 | const memLimit* = 80 7 | const swpLimit* = 50 8 | const rssLimit* = 70 9 | const cpuLimit* = 80 10 | const dskLimit* = 80 11 | const cpuCoreLimit* = 80 12 | const cpuTempLimit* = 80 13 | const ssdTempLimit* = 60 14 | 15 | func checkCpuLimit*(c: CpuInfo): bool = 16 | c.cpu >= cpuLimit 17 | 18 | func checkMemLimit*(m: MemInfo): bool = 19 | memLimit <= checkedDiv(100 * checkedSub(m.MemTotal, m.MemAvailable), m.MemTotal) 20 | 21 | func checkSwpLimit*(m: MemInfo): bool = 22 | swpLimit <= checkedDiv(100 * checkedSub(m.SwapTotal, m.SwapFree), m.SwapTotal) 23 | 24 | func checkCpuTempLimit*(t: Temp): bool = 25 | if t.cpu.isSome: 26 | return t.cpu.get >= cpuTempLimit 27 | 28 | func checkSsdTempLimit*(t: Temp): bool = 29 | if t.nvme.isSome: 30 | return t.nvme.get >= ssdTempLimit 31 | 32 | func checkDiskLimit*(d: Disk): bool = 33 | dskLimit <= checkedDiv(100 * checkedSub(d.total, d.avail), d.total) 34 | 35 | func checkAnyDiskLimit(dd: OrderedTableRef[string, Disk]): bool = 36 | for _, d in dd: 37 | if checkDiskLimit(d): 38 | return true 39 | 40 | func checkAnyLimit*(info: FullInfoRef): bool = 41 | checkCpuLimit(info.cpu) or checkMemLimit(info.mem) or 42 | checkSwpLimit(info.mem) or checkAnyDiskLimit(info.disk) 43 | 44 | -------------------------------------------------------------------------------- /src/ttop/onoff.nim: -------------------------------------------------------------------------------- 1 | from config import PKGDATA, DOCKER_SOCK 2 | 3 | import osproc 4 | import os 5 | import strformat 6 | import strutils 7 | 8 | const unit = "ttop" 9 | const descr = "ttop service snapshot collector" 10 | 11 | const options = {poUsePath, poEchoCmd, poStdErrToStdOut} 12 | 13 | proc createToml(file: string) = 14 | if fileExists file: 15 | return 16 | echo "create ", file 17 | writeFile(file, 18 | """ 19 | # light = false 20 | # docker = """" & DOCKER_SOCK & """" # custom docker socket path 21 | 22 | # [data] 23 | # path = "/var/log/ttop" # custom storage path (default = if exists /var/log/ttop, else ~/.cache/ttop ) 24 | 25 | # [[trigger]] # telegram example 26 | # on_alert = true # execute trigger on alert (true if no other on_* provided) 27 | # on_info = true # execute trigger on without alert (default = false) 28 | # debug = false # output stdout/err from cmd (default = false) 29 | # cmd = ''' 30 | # read -d '' TEXT 31 | # curl -X POST \ 32 | # -H 'Content-Type: application/json' \ 33 | # -d "{\"chat_id\": $CHAT_ID, \"text\": \"$TEXT\", \"disable_notification\": $TTOP_INFO}" \ 34 | # https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage 35 | # ''' 36 | 37 | # # cmd receives text from stdin. The following env vars are set: 38 | # # TTOP_ALERT (true|false) - if alert 39 | # # TTOP_INFO (true|false) - opposite to alert 40 | # # TTOP_TYPE (alert|info) - trigger type 41 | # # TTOP_HOST - host name 42 | # # you can find your CHAT_ID by send smth to your bot and run: 43 | # # curl https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates 44 | 45 | # [[trigger]] # smtp example 46 | # cmd = ''' 47 | # read -d '' TEXT 48 | # TEXT="Subject: ttop $TTOP_TYPE from $TTOP_HOST 49 | # 50 | # $TEXT" 51 | # echo "$TEXT" | curl --ssl-reqd \ 52 | # --url 'smtps://smtp.gmail.com:465' \ 53 | # --user 'login:password' \ 54 | # --mail-from 'from@gmail.com' \ 55 | # --mail-rcpt 'to@gmail.com' \ 56 | # --upload-file - 57 | # ''' 58 | 59 | """) 60 | 61 | proc createService(file: string, app: string) = 62 | if fileExists file: 63 | return 64 | echo "create ", file 65 | writeFile(file, 66 | &""" 67 | [Unit] 68 | Description={descr} 69 | 70 | [Service] 71 | ExecStart={app} -s 72 | 73 | [Install] 74 | WantedBy=ttop.timer 75 | """) 76 | 77 | proc createTimer(file: string, app: string, interval: uint) = 78 | if fileExists file: 79 | return 80 | echo "create ", file 81 | writeFile(file, 82 | &""" 83 | [Unit] 84 | Description={descr} timer 85 | 86 | [Timer] 87 | OnCalendar=*:0/{interval}:08 88 | 89 | [Install] 90 | WantedBy=timers.target 91 | """) 92 | 93 | proc getServiceDir(pkg: bool): string = 94 | if pkg: 95 | result = "usr/lib/systemd/system" 96 | createDir result 97 | elif isAdmin(): 98 | result = "/usr/lib/systemd/system" 99 | else: 100 | result = getConfigDir().joinPath("systemd", "user") 101 | if not dirExists result: 102 | echo "create ", result 103 | createDir result 104 | 105 | proc createConfig(pkg: bool, interval: uint) = 106 | let app = if pkg: "/usr/bin/ttop" else: getAppFilename() 107 | let dir = getServiceDir(pkg) 108 | 109 | createService(dir.joinPath(&"{unit}.service"), app) 110 | createTimer(dir.joinPath(&"{unit}.timer"), app, interval) 111 | 112 | if pkg: 113 | createDir "etc" 114 | createToml("etc/ttop.toml") 115 | 116 | proc deleteConfig() = 117 | let dir = getServiceDir(false) 118 | 119 | var file = dir.joinPath(&"{unit}.service") 120 | echo "rm ", file 121 | removeFile file 122 | file = dir.joinPath(&"{unit}.timer") 123 | echo "rm ", file 124 | removeFile file 125 | 126 | proc cmd(cmd: string, check = false, input = ""): string = 127 | var code: int 128 | (result, code) = execCmdEx(cmd, options = options, input = input) 129 | if result.len > 0: 130 | echo result 131 | if check: 132 | let line0 = result.splitLines()[0] 133 | if line0 == "active": 134 | let msg = "Looks like ttop.timer is already running system-wide" 135 | raise newException(ValueError, "cmd error: " & msg) 136 | elif line0 == "inactive": 137 | return "" 138 | elif line0.startsWith "no crontab for": 139 | return "" 140 | if code != 0: 141 | raise newException(ValueError, "cmd error code: " & $code) 142 | 143 | proc onOffSystemd(enable: bool, interval: uint) = 144 | if isAdmin(): 145 | echo "WARNING: setup via ROOT" 146 | let user = if isAdmin(): "" else: " --user" 147 | if enable: 148 | if not isAdmin(): 149 | discard cmd(&"systemctl is-active '{unit}.timer'", true) 150 | createConfig(false, interval) 151 | discard cmd &"systemctl{user} daemon-reload" 152 | discard cmd &"systemctl{user} enable --now '{unit}.timer'" 153 | if not isAdmin(): 154 | discard cmd "loginctl enable-linger" 155 | if isAdmin(): 156 | echo "mkdir ", PKGDATA 157 | createDir(PKGDATA) 158 | discard cmd &"systemctl{user} start '{unit}.service'" 159 | else: 160 | discard cmd &"systemctl{user} stop '{unit}.timer'" 161 | discard cmd &"systemctl{user} disable --now '{unit}.timer'" 162 | deleteConfig() 163 | discard cmd &"systemctl{user} daemon-reload" 164 | if not isAdmin(): 165 | discard cmd "loginctl disable-linger" 166 | if isAdmin(): 167 | echo "rmdir ", PKGDATA 168 | for k, p in walkDir(PKGDATA): 169 | echo "WARN: ", PKGDATA, " is not empty" 170 | return 171 | removeDir(PKGDATA) 172 | 173 | proc filter(input: string): string = 174 | for l in input.splitLines(true): 175 | if unit in l: 176 | continue 177 | result.add l 178 | 179 | proc onOffCron(enable: bool, interval: uint) = 180 | let output = cmd("crontab -l", true) 181 | var input = filter(output) 182 | let app = getAppFilename() 183 | if enable: 184 | input &= &"*/{interval} * * * * {app} -s\n" 185 | discard cmd("crontab", false, input) 186 | else: 187 | if input == "": 188 | discard cmd "crontab -r" 189 | else: 190 | discard cmd("crontab", false, input) 191 | discard cmd("crontab -l", true) 192 | if enable: 193 | discard cmd &"{app} -s" 194 | 195 | proc onoff*(enable: bool, interval: uint = 10) = 196 | let isSysD = 197 | try: 198 | discard cmd "systemctl is-active --quiet /" 199 | true 200 | except CatchableError, Defect: 201 | false 202 | 203 | if isSysD: 204 | onOffSystemd(enable, interval) 205 | else: 206 | echo "systemd failed, trying crontab" 207 | onOffCron(enable, interval) 208 | 209 | when isMainModule: 210 | createConfig(true, 10) 211 | -------------------------------------------------------------------------------- /src/ttop/procfs.nim: -------------------------------------------------------------------------------- 1 | import sys 2 | import config 3 | 4 | import os 5 | import strutils 6 | from posix import Uid, getpwuid 7 | import posix_utils 8 | import nativesockets 9 | import times 10 | import tables 11 | import algorithm 12 | import strscans 13 | import options 14 | import net 15 | import jsony 16 | 17 | const PROCFS = "/proc" 18 | const PROCFSLEN = PROCFS.len 19 | const SECTOR = 512 20 | var uhz = hz.uint 21 | 22 | const MIN_TEMP = -300000 23 | 24 | type ParseInfoError* = object of ValueError 25 | file*: string 26 | 27 | type SortField* = enum 28 | Cpu, Mem, Io, Pid, Name 29 | 30 | type MemInfo* = object 31 | MemTotal*: uint 32 | MemFree*: uint 33 | MemDiff*: int 34 | MemAvailable*: uint 35 | Buffers*: uint 36 | Cached*: uint 37 | SwapTotal*: uint 38 | SwapFree*: uint 39 | 40 | type PidInfo* = object 41 | pid*: uint 42 | uid*: Uid 43 | ppid*: uint 44 | user*: string 45 | name*: string 46 | state*: string 47 | vsize*: uint 48 | rss*: uint 49 | cpuTime*: uint 50 | cpu*: float 51 | mem*: float 52 | cmd*: string 53 | uptimeHz*: uint 54 | uptime*: uint 55 | ioRead*, ioWrite*: uint 56 | ioReadDiff*, ioWriteDiff*: uint 57 | netIn*, netOut*: uint 58 | netInDiff*, netOutDiff*: uint 59 | parents*: seq[uint] # generated from ppid, used to build tree 60 | threads*: int 61 | isKernel*: bool 62 | count*: int 63 | docker*: string 64 | 65 | type CpuInfo* = object 66 | total*: uint 67 | idle*: uint 68 | cpu*: float 69 | 70 | type SysInfo* = object 71 | datetime*: times.DateTime 72 | hostname*: string 73 | uptimeHz*: uint 74 | 75 | type Disk* = object 76 | avail*: uint 77 | total*: uint 78 | io*: uint 79 | ioRead*: uint 80 | ioWrite*: uint 81 | ioUsage*: uint 82 | ioUsageRead*: uint 83 | ioUsageWrite*: uint 84 | path*: string 85 | 86 | type Net = object 87 | netIn*: uint 88 | netInDiff*: uint 89 | netOut*: uint 90 | netOutDiff*: uint 91 | 92 | type Temp* = object 93 | cpu*: Option[float64] 94 | nvme*: Option[float64] 95 | 96 | type PidsTable = OrderedTableRef[uint, PidInfo] 97 | 98 | type FullInfo* = object 99 | sys*: ref SysInfo 100 | cpu*: CpuInfo 101 | cpus*: seq[CpuInfo] 102 | mem*: MemInfo 103 | pidsInfo*: PidsTable 104 | disk*: OrderedTableRef[string, Disk] 105 | net*: OrderedTableRef[string, Net] 106 | temp*: Temp 107 | # power*: uint 108 | 109 | type FullInfoRef* = ref FullInfo 110 | 111 | type DockerContainer = object 112 | Id: string 113 | Names: seq[string] 114 | 115 | proc newParseInfoError(file: string, parent: ref Exception): ref ParseInfoError = 116 | let parentMsg = if parent != nil: parent.msg else: "nil" 117 | var msg = "error during parsing " & file & ": " & parentMsg 118 | newException(ParseInfoError, msg, parent) 119 | 120 | proc newFullInfo(): FullInfoRef = 121 | new(result) 122 | result.pidsInfo = newOrderedTable[uint, procfs.PidInfo]() 123 | result.disk = newOrderedTable[string, Disk]() 124 | result.net = newOrderedTable[string, Net]() 125 | 126 | proc fullInfo*(prev: FullInfoRef = nil): FullInfoRef 127 | 128 | var prevInfo = newFullInfo() 129 | 130 | template catchErr(file: untyped, filename: string, body: untyped): untyped = 131 | let file: string = filename 132 | try: 133 | body 134 | except CatchableError, Defect: 135 | raise newParseInfoError(file, getCurrentException()) 136 | 137 | proc init*() = 138 | prevInfo = fullInfo() 139 | sleep hz 140 | 141 | proc cut*(str: string, size: int, right: bool, scroll: int): string = 142 | let l = len(str) 143 | if l > size: 144 | let max = min(size+scroll, str.high) 145 | if max >= str.high: 146 | str[^size..max] 147 | elif max - 1 > 0: 148 | if scroll > 0: 149 | "." & str[scroll+1.. b: 171 | return a - b 172 | 173 | proc checkedDiv*(a, b: uint): float = 174 | if b != 0: 175 | return a.float / b.float 176 | 177 | proc parseUptime(): uint = 178 | catchErr(file, PROCFS / "uptime"): 179 | let line = readLines(file, 1)[0] 180 | var f: float 181 | doAssert scanf(line, "$f", f) 182 | uint(float(hz) * f) 183 | 184 | proc parseSize(str: string): uint = 185 | let normStr = str.strip(true, false) 186 | if normStr.endsWith(" kB"): 187 | 1024 * parseUInt(normStr[0..^4]) 188 | elif normStr.endsWith(" mB"): 189 | 1024 * 1024 * parseUInt(normStr[0..^4]) 190 | elif normStr.endsWith("B"): 191 | raise newException(ValueError, "cannot parse: " & normStr) 192 | else: 193 | parseUInt(normStr) 194 | 195 | proc memInfo(): MemInfo = 196 | catchErr(file, PROCFS / "meminfo"): 197 | for line in lines(file): 198 | let parts = line.split(":", 1) 199 | case parts[0] 200 | of "MemTotal": result.MemTotal = parseSize(parts[1]) 201 | of "MemFree": result.MemFree = parseSize(parts[1]) 202 | of "MemAvailable": result.MemAvailable = parseSize(parts[1]) 203 | of "Buffers": result.Buffers = parseSize(parts[1]) 204 | of "Cached": result.Cached = parseSize(parts[1]) 205 | of "SwapTotal": result.SwapTotal = parseSize(parts[1]) 206 | of "SwapFree": result.SwapFree = parseSize(parts[1]) 207 | result.MemDiff = int(result.MemFree) - int(prevInfo.mem.MemFree) 208 | 209 | proc parseStat(pid: uint, uptimeHz: uint, mem: MemInfo): PidInfo = 210 | catchErr(file, PROCFS / $pid / "stat"): 211 | let stat = stat(file) 212 | result.uid = stat.st_uid 213 | let userInfo = getpwuid(result.uid) 214 | if not isNil userInfo: 215 | result.user = $(userInfo.pw_name) 216 | let buf = readFile(file) 217 | 218 | let cmdL = buf.find('(') 219 | let cmdR = buf.rfind(')') 220 | 221 | var pid: int 222 | doAssert scanf(buf[0.. 0 239 | result.uptimeHz = uptimeHz - starttime.uint 240 | result.uptime = result.uptimeHz div uhz 241 | result.cpuTime = utime.uint + stime.uint 242 | 243 | let prevCpuTime = prevInfo.pidsInfo.getOrDefault(result.pid).cpuTime 244 | let delta = 245 | if result.pid in prevInfo.pidsInfo: 246 | checkedSub(result.uptimeHz, prevInfo.pidsInfo[result.pid].uptimeHz) 247 | elif prevInfo.sys != nil: 248 | checkedSub(uptimeHz, prevInfo.sys.uptimeHz) 249 | else: 250 | 0 251 | 252 | result.cpu = checkedDiv(100 * checkedSub(result.cpuTime, prevCpuTime), delta) 253 | result.mem = checkedDiv(100 * result.rss, mem.MemTotal) 254 | 255 | proc parseIO(pid: uint): (uint, uint, uint, uint) = 256 | catchErr(file, PROCFS / $pid / "io"): 257 | var name: string; 258 | var val: int; 259 | for line in lines(file): 260 | doAssert scanf(line, "$w: $i", name, val) 261 | case name 262 | of "read_bytes": 263 | result[0] = val.uint 264 | result[2] = checkedSub(result[0], prevInfo.pidsInfo.getOrDefault(pid).ioRead) 265 | of "write_bytes": 266 | result[1] = val.uint 267 | result[3] = checkedSub(result[1], prevInfo.pidsInfo.getOrDefault(pid).ioWrite) 268 | 269 | proc parseCmd(pid: uint): string = 270 | let file = PROCFS / $pid / "cmdline" 271 | try: 272 | let buf = readFile(file) 273 | result = buf.strip(false, true, {'\0'}).replace('\0', ' ') 274 | result.escape() 275 | except CatchableError: 276 | discard 277 | 278 | proc devName(s: string, o: var string, off: int): int = 279 | while off+result < s.len: 280 | let c = s[off+result] 281 | if not (c.isAlphaNumeric or c in "-_"): 282 | break 283 | o.add c 284 | inc result 285 | 286 | proc parseDocker(pid: uint, hasDocker: var bool): string = 287 | catchErr(file, PROCFS / $pid / "cgroup"): 288 | var tmp0: int 289 | var tmpName, dockerId: string 290 | for line in lines(file): 291 | if scanf(line, "$i:${devName}:/docker/${devName}", tmp0, tmpName, dockerId): 292 | hasDocker = true 293 | return dockerId 294 | 295 | proc parsePid(pid: uint, uptimeHz: uint, mem: MemInfo, 296 | hasDocker: var bool): PidInfo = 297 | try: 298 | result = parseStat(pid, uptimeHz, mem) 299 | let io = parseIO(pid) 300 | result.ioRead = io[0] 301 | result.ioReadDiff = io[2] 302 | result.ioWrite = io[1] 303 | result.ioWriteDiff = io[3] 304 | except ParseInfoError: 305 | let ex = getCurrentException() 306 | if ex.parent of IOError: 307 | result.cmd = "IOError" 308 | else: 309 | raise 310 | result.cmd = parseCmd(pid) 311 | result.docker = parseDocker(pid, hasDocker) 312 | 313 | iterator pids*(): uint = 314 | catchErr(dir, PROCFS): 315 | for f in walkDir(dir): 316 | if f.kind == pcDir: 317 | try: 318 | yield parseUInt f.path[1+PROCFSLEN..^1] 319 | except ValueError: 320 | discard 321 | 322 | proc pidsInfo*(uptimeHz: uint, memInfo: MemInfo, 323 | hasDocker: var bool): OrderedTableRef[uint, PidInfo] = 324 | result = newOrderedTable[uint, PidInfo]() 325 | for pid in pids(): 326 | try: 327 | result[pid] = parsePid(pid, uptimeHz, memInfo, hasDocker) 328 | except ParseInfoError: 329 | let ex = getCurrentException() 330 | if ex.parent of OSError: 331 | if osLastError() != OSErrorCode(2): 332 | raise 333 | else: 334 | raise 335 | 336 | proc getOrDefault(s: seq[CpuInfo], i: int): CpuInfo = 337 | if i < s.len: 338 | return s[i] 339 | 340 | proc parseStat(): (CpuInfo, seq[CpuInfo]) = 341 | catchErr(file, PROCFS / "stat"): 342 | var name: string 343 | var idx, v1, v2, v3, v4, v5, v6, v7, v8: int 344 | 345 | for line in lines(file): 346 | if line.startsWith("cpu"): 347 | doAssert scanf(line, "$w $s$i $i $i $i $i $i $i $i", name, v1, v2, v3, 348 | v4, v5, v6, v7, v8) 349 | let total = uint(v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8) 350 | let idle = uint(v4 + v5) 351 | 352 | if scanf(name, "cpu$i", idx): 353 | let curTotal = checkedSub(total, prevInfo.cpus.getOrDefault(result[1].len).total) 354 | let curIdle = checkedSub(idle, prevInfo.cpus.getOrDefault(result[1].len).idle) 355 | let cpu = checkedDiv(100 * (curTotal - curIdle), curTotal) 356 | result[1].add CpuInfo(total: total, idle: idle, cpu: cpu) 357 | else: 358 | let curTotal = checkedSub(total, prevInfo.cpu.total) 359 | let curIdle = checkedSub(idle, prevInfo.cpu.idle) 360 | let cpu = checkedDiv(100 * (curTotal - curIdle), curTotal) 361 | result[0] = CpuInfo(total: total, idle: idle, cpu: cpu) 362 | 363 | proc sysInfo*(): ref SysInfo = 364 | new(result) 365 | result.datetime = times.now() 366 | result.hostname = getHostName() 367 | result.uptimeHz = parseUptime() 368 | 369 | proc diskInfo*(): OrderedTableRef[string, Disk] = 370 | result = newOrderedTable[string, Disk]() 371 | catchErr(file, PROCFS / "mounts"): 372 | for line in lines(file): 373 | if line.startsWith("/dev/"): 374 | if line.startsWith("/dev/loop"): 375 | continue 376 | let parts = line.split(maxsplit = 2) 377 | let name = parts[0] 378 | if name in result: 379 | continue 380 | let path = parts[1] 381 | var stat: Statvfs 382 | if statvfs(cstring path, stat) != 0: 383 | continue 384 | result[name] = Disk(avail: stat.f_bfree * stat.f_bsize, 385 | total: stat.f_blocks * stat.f_bsize, 386 | path: path) 387 | 388 | catchErr(file2, PROCFS / "diskstats"): 389 | for line in lines(file2): 390 | var tmp, read, write, total: int 391 | var name: string 392 | doAssert scanf(line, "$s$i $s$i ${devName} $i $i $i $i $i $i $i $i $i $i", 393 | tmp, tmp, name, tmp, tmp, tmp, read, tmp, tmp, tmp, write, tmp, total) 394 | 395 | if name notin result: 396 | continue 397 | 398 | let io = SECTOR * total.uint 399 | result[name].io = io 400 | result[name].ioUsage = checkedSub(io, prevInfo.disk.getOrDefault(name).io) 401 | let ioRead = SECTOR * read.uint 402 | result[name].ioRead = ioRead 403 | result[name].ioUsageRead = checkedSub(ioRead, prevInfo.disk.getOrDefault(name).ioRead) 404 | let ioWrite = SECTOR * write.uint 405 | result[name].ioWrite = ioWrite 406 | result[name].ioUsageWrite = checkedSub(ioWrite, 407 | prevInfo.disk.getOrDefault(name).ioWrite) 408 | 409 | return result 410 | 411 | proc netInfo(): OrderedTableRef[string, Net] = 412 | result = newOrderedTable[string, Net]() 413 | catchErr(file, PROCFS / "net/dev"): 414 | var i = 0 415 | for line in lines(file): 416 | inc i 417 | if i in 1..2: 418 | continue 419 | var name: string 420 | var tmp, netIn, netOut: int 421 | if not scanf(line, "$s${devName}:$s$i$s$i$s$i$s$i$s$i$s$i$s$i$s$i$s$i", 422 | name, netIn, tmp, tmp, tmp, tmp, tmp, tmp, tmp, netOut): 423 | continue 424 | if name.startsWith("veth"): 425 | continue 426 | 427 | result[name] = Net( 428 | netIn: netIn.uint, 429 | netInDiff: checkedSub(netIn.uint, prevInfo.net.getOrDefault( 430 | name).netIn), 431 | netOut: netOut.uint, 432 | netOutDiff: checkedSub(netOut.uint, prevInfo.net.getOrDefault(name).netOut) 433 | ) 434 | 435 | proc findMaxTemp(dir: string): Option[float64] = 436 | var maxTemp = MIN_TEMP 437 | for file in walkFiles(dir /../ "temp*_input"): 438 | for line in lines(file): 439 | let temp = parseInt(line) 440 | if temp > maxTemp: 441 | maxTemp = temp 442 | break 443 | if maxTemp != MIN_TEMP: 444 | return some(maxTemp / 1000) 445 | 446 | proc tempInfo(): Temp = 447 | var cnt = 0 448 | for file in walkFiles("/sys/class/hwmon/hwmon*/name"): 449 | case readFile(file) 450 | of "coretemp\n", "k10temp\n": 451 | result.cpu = findMaxTemp(file) 452 | cnt.inc 453 | if cnt == 2: break 454 | of "nvme\n": 455 | result.nvme = findMaxTemp(file) 456 | cnt.inc 457 | if cnt == 2: break 458 | else: 459 | discard 460 | 461 | proc getDockerContainers(): Table[string, string] = 462 | try: 463 | let socket = newSocket(AF_UNIX, SOCK_STREAM, IPPROTO_IP, false) 464 | socket.connectUnix(getCfg().docker) 465 | socket.send("GET /containers/json HTTP/1.1\nHost: v1.42\n\n") 466 | defer: socket.close() 467 | var inContent = false 468 | var chunked = false 469 | var content = "" 470 | while true: 471 | let str = socket.recvLine() 472 | if inContent: 473 | if chunked: 474 | let sz = fromHex[int](str) 475 | if sz == 0: 476 | break 477 | content.add socket.recv(sz, 100) 478 | inContent = false 479 | else: 480 | content.add str 481 | break 482 | elif "Transfer-Encoding: chunked" == str: 483 | chunked = true 484 | elif str == "\13\10": 485 | inContent = true 486 | elif str == "": 487 | break 488 | for c in content.fromJson(seq[DockerContainer]): 489 | if c.Names.len > 0: 490 | var name = c.Names[0] 491 | removePrefix(name, '/') 492 | result[c.Id] = name 493 | except CatchableError: 494 | discard 495 | 496 | proc fullInfo*(prev: FullInfoRef = nil): FullInfoRef = 497 | result = newFullInfo() 498 | 499 | if prev != nil: 500 | prevInfo = prev 501 | 502 | result.sys = sysInfo() 503 | (result.cpu, result.cpus) = parseStat() 504 | result.mem = memInfo() 505 | var hasDocker = false 506 | result.pidsInfo = pidsInfo(result.sys.uptimeHz, result.mem, hasDocker) 507 | if hasDocker: 508 | let dockers = getDockerContainers() 509 | for pid, pi in result.pidsInfo: 510 | if pi.docker.len > 0: 511 | if pi.docker in dockers: 512 | result.pidsInfo[pid].docker = dockers[pi.docker] 513 | else: 514 | result.pidsInfo[pid].docker = pi.docker[0..min(11, pi.docker.high)] 515 | 516 | result.disk = diskInfo() 517 | result.net = netInfo() 518 | result.temp = tempInfo() 519 | prevInfo = result 520 | 521 | proc sortFunc(sortOrder: SortField, threads = false): auto = 522 | case sortOrder 523 | of Pid: return proc(a, b: (uint, PidInfo)): int = 524 | cmp a[1].pid, b[1].pid 525 | of Name: return proc(a, b: (uint, PidInfo)): int = 526 | cmp a[1].name, b[1].name 527 | of Mem: return proc(a, b: (uint, PidInfo)): int = 528 | cmp b[1].rss, a[1].rss 529 | of Io: return proc(a, b: (uint, PidInfo)): int = 530 | cmp b[1].ioReadDiff+b[1].ioWriteDiff, a[1].ioReadDiff+a[1].ioWriteDiff 531 | of Cpu: return proc(a, b: (uint, PidInfo)): int = 532 | cmp b[1].cpu, a[1].cpu 533 | 534 | proc genParents(p: OrderedTableRef[uint, PidInfo]) = 535 | for k, v in p: 536 | if v.parents.len > 0: 537 | continue 538 | var s: seq[uint] = @[] 539 | var x = v.ppid 540 | while x in p: 541 | s.add x 542 | x = p[x].ppid 543 | let parents = s.reversed() 544 | p[k].parents = parents 545 | 546 | proc sort*(pidsInfo: PidsTable, sortOrder = Pid, threads = false, 547 | group = false) = 548 | if threads: 549 | pidsInfo.genParents() 550 | let cmpFn = sortFunc(sortOrder, false) 551 | pidsInfo.sort(proc(a, b: (uint, PidInfo)): int = 552 | var i = 0 553 | while i < a[1].parents.len and i < b[1].parents.len: 554 | result = cmp(a[1].parents[i], b[1].parents[i]) 555 | if result == 0: 556 | inc i 557 | else: 558 | return result 559 | result = cmp(a[1].parents.len, b[1].parents.len) 560 | if result == 0: 561 | result = cmpFn((a[0], pidsInfo[a[0]]), (b[0], pidsInfo[b[0]])) 562 | ) 563 | 564 | elif sortOrder != Pid: 565 | sort(pidsInfo, sortFunc(sortOrder)) 566 | 567 | proc getExe(cmd: string): string = 568 | let idx = cmd.find({' ', ':'}) 569 | if idx >= 0: 570 | cmd[0..".} = culong 5 | Fsfilcnt* {.importc: "fsfilcnt_t", header: "".} = culong 6 | 7 | Statvfs* {.importc: "struct statvfs", header: "", 8 | final, pure.} = object ## struct statvfs 9 | f_bsize*: culong ## File system block size. 10 | f_frsize*: culong ## Fundamental file system block size. 11 | f_blocks*: Fsblkcnt ## Total number of blocks on file system 12 | ## in units of f_frsize. 13 | f_bfree*: Fsblkcnt ## Total number of free blocks. 14 | f_bavail*: Fsblkcnt ## Number of free blocks available to 15 | ## non-privileged process. 16 | f_files*: Fsfilcnt ## Total number of file serial numbers. 17 | f_ffree*: Fsfilcnt ## Total number of free file serial numbers. 18 | f_favail*: Fsfilcnt ## Number of file serial numbers available to 19 | ## non-privileged process. 20 | f_fsid*: culong ## File system ID. 21 | f_flag*: culong ## Bit mask of f_flag values. 22 | f_namemax*: culong ## Maximum filename length. 23 | # f_spare: array[6, cint] 24 | 25 | let hz* = sysconf(SC_CLK_TCK) 26 | var pageSize* = uint sysconf(SC_PAGESIZE) 27 | 28 | proc statvfs*(a1: cstring, a2: var Statvfs): cint {. 29 | importc, header: "".} 30 | -------------------------------------------------------------------------------- /src/ttop/triggers.nim: -------------------------------------------------------------------------------- 1 | import procfs 2 | import limits 3 | import format 4 | import config 5 | 6 | import strformat 7 | import tables 8 | import times 9 | import osproc 10 | import streams 11 | import strtabs 12 | import os 13 | 14 | const defaultTimeout = initDuration(seconds = 5) 15 | 16 | proc genText(info: FullInfoRef, alarm: bool): (string, string) = 17 | let host = info.sys.hostname 18 | let cpuStr = info.cpu.cpu.formatP(true) 19 | let memStr = formatD(info.mem.MemAvailable, info.mem.MemTotal) 20 | let alarmStr = if alarm: "❌" else: "✅" 21 | 22 | result[0] = &""" 23 | 24 | Status: {alarmStr} 25 | Host: {host} 26 | Cpu: {cpuStr} 27 | Mem: {memStr} 28 | Dsk: 29 | """ 30 | for k, d in info.disk: 31 | result[0].add &" {k}: {formatD(d.avail, d.total)}\n" 32 | 33 | # result[0].add &"\n{info.sys.datetime}\n" 34 | 35 | if alarm: 36 | result[1] = &"Alarm ttop from {host}" 37 | else: 38 | result[1] = &"ttop from {host}" 39 | 40 | proc exec(cmd: string, body, subj, host: string, alert, debug: bool) = 41 | if debug: 42 | echo "CMD: ", cmd 43 | 44 | let env = newStringTable() 45 | env["TTOP_ALERT"] = $alert 46 | env["TTOP_INFO"] = $(not alert) 47 | env["TTOP_TEXT"] = body 48 | env["TTOP_TYPE"] = if alert: "alert" else: "info" 49 | env["TTOP_HOST"] = host 50 | for k, v in envPairs(): 51 | env[k] = v 52 | 53 | var p = startProcess(cmd, env = env, options = {poEvalCommand, 54 | poStdErrToStdOut}) 55 | let output = p.outputStream() 56 | let input = p.inputStream() 57 | 58 | input.write(body) 59 | input.close() 60 | 61 | var line = "" 62 | var code = -1 63 | let start = now() 64 | while true: 65 | if output.readLine(line): 66 | if debug: 67 | echo "> ", line 68 | else: 69 | code = peekExitCode(p) 70 | if code != -1: 71 | if debug: 72 | echo "CODE: ", code 73 | break 74 | if now() - start > defaultTimeout: 75 | if debug: 76 | echo "TIMEOUT" 77 | terminate(p) 78 | break 79 | close(p) 80 | 81 | proc smtpSave*(info: FullInfoRef) = 82 | let alarm = checkAnyLimit(info) 83 | let (body, subj) = genText(info, alarm) 84 | let host = info.sys.hostname 85 | 86 | let cfg = getCfg() 87 | 88 | for t in cfg.triggers: 89 | if (alarm and t.onAlert) or t.onInfo: 90 | exec(t.cmd, body, subj, host, alarm, t.debug) 91 | 92 | -------------------------------------------------------------------------------- /src/ttop/tui.nim: -------------------------------------------------------------------------------- 1 | import illwill 2 | import os 3 | import procfs 4 | import config 5 | import strutils 6 | import strformat 7 | import tables 8 | import times 9 | import std/monotimes 10 | import options 11 | import limits 12 | import format 13 | import sequtils 14 | import blog 15 | import asciigraph 16 | from terminal import setCursorXPos 17 | 18 | const fgDarkColor = fgWhite 19 | const fgLightColor = fgBlack 20 | var fgColor = fgDarkColor 21 | 22 | const offset = 2 23 | const HelpCol = fgGreen 24 | 25 | type 26 | Tui = ref object 27 | sort: SortField 28 | scrollX: int 29 | scrollY: int 30 | filter: Option[string] 31 | threads: bool 32 | group: bool 33 | kernel: bool 34 | forceLive: bool 35 | draw: bool 36 | reload: bool 37 | quit: bool 38 | hist: int 39 | blog: string 40 | refresh: bool 41 | 42 | proc stopTui() {.noconv.} = 43 | illwillDeinit() 44 | setCursorXPos(0) 45 | showCursor() 46 | 47 | proc exitProc() {.noconv.} = 48 | stopTui() 49 | quit(0) 50 | 51 | proc writeR(tb: var TerminalBuffer, s: string, rOffset = 0) = 52 | let x = terminalWidth() - s.len - offset - rOffset 53 | if tb.getCursorXPos < x: 54 | tb.setCursorXPos x 55 | tb.write(s) 56 | 57 | proc chunks[T](x: openArray[T], n: int): seq[seq[T]] = 58 | var i = 0 59 | while i < x.len: 60 | result.add x[i.. 0: 81 | tb.write fmt" {blog} {tui.hist}/{cnt} " 82 | elif blog == "": 83 | tb.write fmt" autoupdate log: empty " 84 | else: 85 | tb.write fmt" autoupdate {blog} {cnt}/{cnt} " 86 | let curX = tb.getCursorXPos() 87 | if tb.width - curX - 2 > 0: 88 | tb.write ' '.repeat(tb.width - curX - 2) 89 | tb.setCursorXPos curX 90 | tb.write resetStyle 91 | # let powerStr = fmt"{float(info.power) / 1000000:5.2f} W" 92 | let procStr = fmt"PROCS: {$info.pidsInfo.len}" 93 | tb.writeR procStr 94 | tb.setCursorPos(offset, 2) 95 | tb.write bgNone 96 | tb.write fgYellow, "CPU: ", fgNone 97 | if checkCpuLimit(info.cpu): 98 | tb.write bgRed 99 | tb.write styleBright, info.cpu.cpu.formatP(true), bgNone, " %|" 100 | for i, cpu in info.cpus: 101 | if i > 0: 102 | tb.write "|" 103 | if checkCpuLimit(cpu): 104 | tb.write fgYellow, formatP(cpu.cpu), fgNone, styleBright 105 | else: 106 | tb.write formatP(cpu.cpu) 107 | tb.write "|%" 108 | temp(tb, info.temp.cpu, checkCpuTempLimit(info.temp)) 109 | tb.setCursorPos(offset, 3) 110 | let memStr = formatD(mi.MemAvailable, mi.MemTotal) 111 | let sign = if mi.MemDiff > 0: '+' elif mi.MemDiff == 0: '=' else: '-' 112 | if checkMemLimit(mi): 113 | tb.write bgRed 114 | tb.write fgGreen, "MEM: ", fgNone, fgColor, styleBright, memStr 115 | tb.write fmt" {sign&abs(mi.MemDiff).formatS():>9} BUF: {mi.Buffers.formatS()} CACHE: {mi.Cached.formatS()}" 116 | if checkSwpLimit(mi): 117 | tb.write bgRed 118 | tb.write fmt" SWP: {formatD(mi.SwapFree, mi.SwapTotal)}", bgNone 119 | 120 | let diskMatrix = chunks(info.disk.keys().toSeq(), 2) 121 | for i, diskRow in diskMatrix: 122 | tb.setCursorPos offset, 4+i 123 | if i == 0: 124 | tb.write fgCyan, "DSK: ", styleBright 125 | else: 126 | tb.write " " 127 | for i, k in diskRow: 128 | if i > 0: 129 | tb.write " | " 130 | let disk = info.disk[k] 131 | let bg = if checkDiskLimit(disk): bgRed else: bgNone 132 | tb.write fgMagenta, disk.path, fgColor, " ", bg, 133 | fmt"{formatD(disk.avail, disk.total)}", bgNone, 134 | fmt" (rw: {formatS(disk.ioUsageRead, disk.ioUsageWrite)})" 135 | if i == 0: 136 | temp(tb, info.temp.nvme, checkSsdTempLimit(info.temp)) 137 | 138 | var netKeys = newSeq[string]() 139 | for k, v in info.net: 140 | if v.netIn == 0 and v.netOut == 0: 141 | continue 142 | netKeys.add k 143 | let netMatrix = chunks(netKeys, 4) 144 | var y = tb.getCursorYPos()+1 145 | for i, netRow in netMatrix: 146 | tb.setCursorPos offset, y+i 147 | if i == 0: 148 | tb.write fgMagenta, "NET: ", styleBright 149 | else: 150 | tb.write " " 151 | for i, k in netRow: 152 | if i > 0: 153 | tb.write " | " 154 | let net = info.net[k] 155 | tb.write fgCyan, k, fgColor, " ", formatS(net.netInDiff, 156 | net.netOutDiff) 157 | 158 | proc graphData(stats: seq[StatV2], sort: SortField, width: int): seq[float] = 159 | case sort: 160 | of Cpu: result = stats.mapIt(it.cpu) 161 | of Mem: result = stats.mapIt(int(it.memTotal - it.memAvailable).formatSPair()[0]) 162 | of Io: result = stats.mapIt(float(it.io)) 163 | else: result = stats.mapIt(float(it.prc)) 164 | 165 | if result.len < width: 166 | let diff = width - stats.len 167 | result.insert(float(0).repeat(diff), 0) 168 | 169 | proc graph(tui: Tui, tb: var TerminalBuffer, stats, live: seq[StatV2], 170 | blog: string) = 171 | tb.setCursorPos offset, tb.getCursorYPos()+1 172 | var y = tb.getCursorYPos() + 1 173 | tb.setCursorPos offset, y 174 | let w = terminalWidth() 175 | let graphWidth = w - 12 176 | let data = 177 | if tui.forceLive or stats.len == 0: graphData(live, tui.sort, graphWidth) 178 | else: graphData(stats, tui.sort, 0) 179 | try: 180 | let gLines = plot(data, width = graphWidth, height = 4).split("\n") 181 | y += 5 - gLines.len 182 | for i, g in gLines: 183 | tb.setCursorPos offset-1, y+i 184 | tb.write g 185 | if tui.hist > 0 and not tui.forceLive: 186 | let cc = if data.len > 2: data.len - 1 else: 1 187 | let x = ((tui.hist-1) * (w-11-2)) div (cc) 188 | tb.setCursorPos offset + 8 + x, tb.getCursorYPos() + 1 189 | tb.write styleBright, "^" 190 | else: 191 | tb.setCursorPos offset, tb.getCursorYPos() + 1 192 | if stats.len == 0 or tui.forceLive: 193 | if stats.len == 0: 194 | tb.writeR("No historical stats found ", 5) 195 | tb.write bgGreen 196 | tb.writeR "LIVE" 197 | tb.write bgNone 198 | else: 199 | tb.writeR blog 200 | except CatchableError, Defect: 201 | tb.write("error in graph: " & $deduplicate(data)) 202 | tb.setCursorPos offset, tb.getCursorYPos() + 1 203 | 204 | proc timeButtons(tb: var TerminalBuffer, cnt: int) = 205 | if cnt == 0: 206 | tb.write " ", styleDim, "[]", fgNone, ",", HelpCol, "{} - timeshift ", 207 | styleBright, fgNone 208 | else: 209 | tb.write " ", HelpCol, "[]", fgNone, ",", HelpCol, "{}", fgNone, " - timeshift " 210 | 211 | proc help(tui: Tui, tb: var TerminalBuffer, w, h, cnt: int) = 212 | tb.setCursorPos offset - 1, tb.height - 1 213 | 214 | tb.write fgNone 215 | for x in SortField: 216 | if x == tui.sort: 217 | tb.write " ", styleBright, fgNone, $x 218 | else: 219 | tb.write " ", HelpCol, $($x)[0], fgCyan, ($x)[1..^1] 220 | 221 | if tui.group: 222 | tb.write " ", styleBright, fgNone 223 | else: 224 | tb.write " ", HelpCol 225 | tb.write "G", fgNone, " - group" 226 | if tui.threads: 227 | tb.write " ", styleBright, fgNone 228 | else: 229 | tb.write " ", HelpCol 230 | tb.write "T", fgNone, " - tree" 231 | tb.write " ", HelpCol, "/", fgNone, " - filter " 232 | timeButtons(tb, cnt) 233 | if tui.forceLive or cnt == 0: 234 | tb.write " ", styleBright, fgNone, "L", fgNone, " - live " 235 | else: 236 | tb.write " ", HelpCol, "L", fgNone, " - live " 237 | tb.write " ", HelpCol, "Esc,Q", fgNone, " - quit " 238 | 239 | let x = tb.getCursorXPos() 240 | 241 | if x + 26 < w: 242 | if tui.scrollX > 0: 243 | tb.setCursorXPos(w - 26) 244 | tb.write fmt" X: {tui.scrollX}" 245 | if tui.scrollY > 0: 246 | tb.setCursorXPos(w - 21) 247 | tb.write fmt" Y: {tui.scrollY}" 248 | 249 | if x + 15 < w: 250 | tb.setCursorXPos(w - 15) 251 | if tui.scrollX > 0 or tui.scrollY > 0: 252 | tb.write HelpCol, " ", fgNone 253 | else: 254 | tb.write " " 255 | tb.write fmt "WH: {w}x{h} " 256 | 257 | proc checkFilter(filter: string, p: PidInfo): bool = 258 | for fWord in filter.split(): 259 | if fWord == "@": 260 | if p.user == "root": 261 | result = true 262 | elif fWord.startsWith("@"): 263 | if p.user == "": 264 | if fWord[1..^1] notin ($p.uid): 265 | result = true 266 | elif fWord[1..^1] notin p.user: 267 | result = true 268 | elif fWord == "#": 269 | if p.docker == "": 270 | result = true 271 | elif fWord.startsWith("#"): 272 | if fWord[1..^1] notin p.docker: 273 | result = true 274 | elif fWord notin $p.pid and 275 | fWord notin toLowerAscii(p.cmd) and 276 | fWord notin toLowerAscii(p.name) and 277 | fWord notin toLowerAscii(p.docker): 278 | result = true 279 | 280 | proc table(tui: Tui, tb: var TerminalBuffer, pi: OrderedTableRef[uint, PidInfo], 281 | statsLen: int) = 282 | var y = tb.getCursorYPos() + 1 283 | tb.write styleDim 284 | tb.write(offset, y, styleDim, fmt"""{"S":1}""") 285 | if not tui.group: 286 | if tui.sort == Pid: tb.write resetStyle else: tb.write styleDim 287 | tb.write fmt""" {"PID":>6}""" 288 | else: 289 | tb.write fmt""" {"CNT":>6}""" 290 | tb.write styleDim, fmt""" {"USER":<8}""" 291 | if tui.sort == Mem: tb.write resetStyle else: tb.write styleDim 292 | tb.write fmt""" {"RSS":>9} {"MEM%":>5}""" 293 | if tui.sort == Cpu: tb.write resetStyle else: tb.write styleDim 294 | tb.write fmt""" {"CPU%":>5}""" 295 | if tui.sort == IO: tb.write resetStyle else: tb.write styleDim 296 | tb.write fmt""" {"r/w IO":>9}""" 297 | tb.write styleDim, fmt""" {"UP":>8} {"THR":>3}""" 298 | if tb.width - 67 > 0: 299 | tb.write ' '.repeat(tb.width-67), bgNone 300 | inc y 301 | var i = 0 302 | tb.setStyle {} 303 | tb.write fgColor 304 | if tui.scrollY > 0: 305 | tb.setCursorPos (tb.width div 2)-1, tb.getCursorYPos()+1 306 | tb.write "..." 307 | inc y 308 | dec i 309 | for (_, p) in pi.pairs: 310 | if not tui.kernel and p.isKernel: 311 | continue 312 | if tui.filter.isSome: 313 | if checkFilter(tui.filter.get, p): 314 | continue 315 | elif i < tui.scrollY: 316 | inc i 317 | continue 318 | tb.setCursorPos offset, y 319 | tb.write p.state 320 | if tui.group: 321 | tb.write " ", p.count.formatN3() 322 | else: 323 | tb.write " ", p.pid.cut(6, true, tui.scrollX) 324 | if p.user == "": 325 | tb.write " ", fgMagenta, int(p.uid).cut(8, false, tui.scrollX), fgColor 326 | else: 327 | tb.write " ", fgCyan, p.user.cut(8, false, tui.scrollX), fgColor 328 | if p.mem >= rssLimit: 329 | tb.write bgRed 330 | tb.write " ", p.rss.formatS().cut(9, true, tui.scrollX), bgNone 331 | if p.mem >= rssLimit: 332 | tb.write bgRed 333 | tb.write " ", p.mem.formatP().cut(5, true, tui.scrollX), bgNone 334 | if p.cpu >= cpuLimit: 335 | tb.write bgRed 336 | tb.write " ", p.cpu.formatP().cut(5, true, tui.scrollX), bgNone 337 | var rwStr = "" 338 | if p.ioReadDiff + p.ioWriteDiff > 0: 339 | rwStr = fmt"{formatSI(p.ioReadDiff, p.ioWriteDiff)}" 340 | tb.write " ", rwStr.cut(9, true, tui.scrollX) 341 | 342 | tb.write " ", p.uptime.formatT().cut(8, false, tui.scrollX) 343 | 344 | let lvl = p.parents.len 345 | var cmd = "" 346 | tb.write " ", p.threads.formatN3(), " " 347 | if tui.threads and lvl > 0: 348 | tb.write fgCyan, repeat("·", lvl) 349 | if p.docker != "": 350 | tb.write fgBlue, p.docker & ":" 351 | if p.cmd != "": 352 | cmd.add p.cmd 353 | else: 354 | cmd.add p.name 355 | tb.write fgCyan, cmd.cut(tb.width - 67 - lvl - p.docker.len - 2, false, 356 | tui.scrollX), fgColor 357 | 358 | inc y 359 | if y > tb.height-3: 360 | tb.setCursorPos (tb.width div 2)-1, tb.getCursorYPos()+1 361 | tb.write "..." 362 | break 363 | 364 | proc showFilter(tui: Tui, tb: var TerminalBuffer, cnt: int) = 365 | tb.setCursorPos offset, tb.height - 1 366 | timeButtons(tb, cnt) 367 | tb.write " ", HelpCol, "@", fgNone, ",", HelpCol, "#", fgNone, " - by user,docker" 368 | tb.write " ", HelpCol, "Esc", fgNone, ",", HelpCol, "Ret", fgNone, " - Back " 369 | tb.write " Filter: ", bgBlue, tui.filter.get(), bgNone 370 | 371 | proc redraw(tui: Tui, info: FullInfoRef, stats, live: seq[StatV2]) = 372 | let (w, h) = terminalSize() 373 | var tb = newTerminalBuffer(w, h) 374 | 375 | if info == nil: 376 | tb.write fmt"blog not found {tui.blog}: {tui.hist} / {stats.len}" 377 | tb.display() 378 | return 379 | 380 | if checkAnyLimit(info): 381 | tb.setForegroundColor(fgRed, true) 382 | tb.drawRect(0, 0, w-1, h-1, true) 383 | 384 | let blogShort = extractFilename tui.blog 385 | tui.header(tb, info, stats.len, blogShort) 386 | tui.graph(tb, stats, live, blogShort) 387 | let pidsInfo = 388 | if tui.group: 389 | info.pidsInfo.group(tui.kernel) 390 | else: 391 | info.pidsInfo 392 | pidsInfo.sort(tui.sort, tui.threads) 393 | tui.table(tb, pidsInfo, stats.len) 394 | if tui.filter.isSome: 395 | tui.showFilter(tb, stats.len) 396 | else: 397 | tui.help(tb, w, h, stats.len) 398 | tb.display() 399 | 400 | proc processKey(tui: Tui, key: Key, stats: var seq[StatV2]) = 401 | if key == Key.None: 402 | tui.refresh = true 403 | return 404 | if tui.filter.isNone: 405 | case key 406 | of Key.Escape, Key.Q: tui.quit = true 407 | of Key.Space: tui.draw = true 408 | of Key.Left: 409 | if tui.scrollX > 0: dec tui.scrollX 410 | tui.draw = true 411 | of Key.Right: 412 | inc tui.scrollX; 413 | tui.draw = true 414 | of Key.Up: 415 | if tui.scrollY > 0: dec tui.scrollY 416 | tui.draw = true 417 | of Key.PageUp: 418 | if tui.scrollY > 0: tui.scrollY -= 10 419 | if tui.scrollY < 0: tui.scrollY = 0 420 | tui.draw = true 421 | of Key.Down: inc tui.scrollY; tui.draw = true 422 | of Key.PageDown: tui.scrollY += 10; tui.draw = true 423 | of Key.Z: tui.scrollX = 0; tui.scrollY = 0; tui.draw = true 424 | of Key.P: tui.sort = Pid; tui.draw = true 425 | of Key.M: tui.sort = Mem; tui.draw = true 426 | of Key.I: tui.sort = Io; tui.draw = true 427 | of Key.N: tui.sort = Name; tui.draw = true 428 | of Key.C: tui.sort = Cpu; tui.draw = true 429 | of Key.T: 430 | tui.threads = not tui.threads 431 | if tui.threads: tui.group = false 432 | tui.draw = true 433 | of Key.G: 434 | tui.group = not tui.group 435 | if tui.group: tui.threads = false 436 | tui.draw = true 437 | of Key.K: 438 | tui.kernel = not tui.kernel 439 | tui.draw = true 440 | of Key.L: tui.forceLive = not tui.forceLive; tui.reload = true 441 | of Key.Slash: tui.filter = some(""); tui.draw = true 442 | of Key.LeftBracket: 443 | if not tui.forceLive: 444 | (tui.blog, tui.hist) = moveBlog(-1, tui.blog, tui.hist, stats.len) 445 | else: 446 | tui.forceLive = not tui.forceLive 447 | tui.reload = true 448 | of Key.RightBracket: 449 | if not tui.forceLive: 450 | (tui.blog, tui.hist) = moveBlog(+1, tui.blog, tui.hist, stats.len) 451 | tui.reload = true 452 | of Key.LeftBrace: 453 | if not tui.forceLive: 454 | (tui.blog, tui.hist) = moveBlog(-1, tui.blog, 1, stats.len) 455 | tui.reload = true 456 | of Key.RightBrace: 457 | if not tui.forceLive: 458 | (tui.blog, tui.hist) = moveBlog(+1, tui.blog, stats.len, stats.len) 459 | tui.reload = true 460 | else: discard 461 | else: 462 | case key 463 | of Key.Escape, Key.Enter: 464 | tui.filter = none(string) 465 | tui.draw = true 466 | of Key.A .. Key.Z: 467 | tui.filter.get().add char(key) 468 | tui.draw = true 469 | of Key.At, Key.Hash, Key.Slash, Key.Backslash, Key.Colon, Key.Space, 470 | Key.Minus, Key.Plus, Key.Underscore, Key.Comma, Key.Dot, Key.Ampersand: 471 | tui.filter.get().add char(key) 472 | tui.draw = true 473 | of Key.Zero .. Key.Nine: 474 | tui.filter.get().add char(key) 475 | tui.draw = true 476 | of Key.Backspace: 477 | if tui.filter.get().len > 0: 478 | tui.filter.get() = tui.filter.get[0..^2] 479 | tui.draw = true 480 | of Key.Left: 481 | if tui.scrollX > 0: dec tui.scrollX 482 | tui.draw = true 483 | of Key.Right: 484 | inc tui.scrollX; 485 | tui.draw = true 486 | of Key.LeftBracket: 487 | if not tui.forceLive: 488 | (tui.blog, tui.hist) = moveBlog(-1, tui.blog, tui.hist, stats.len) 489 | else: 490 | tui.forceLive = not tui.forceLive 491 | tui.reload = true 492 | of Key.RightBracket: 493 | if not tui.forceLive: 494 | (tui.blog, tui.hist) = moveBlog(+1, tui.blog, tui.hist, stats.len) 495 | tui.reload = true 496 | of Key.LeftBrace: 497 | if not tui.forceLive: 498 | (tui.blog, tui.hist) = moveBlog(-1, tui.blog, 1, stats.len) 499 | tui.reload = true 500 | of Key.RightBrace: 501 | if not tui.forceLive: 502 | (tui.blog, tui.hist) = moveBlog(+1, tui.blog, stats.len, stats.len) 503 | tui.reload = true 504 | else: discard 505 | 506 | 507 | proc postProcess(tui: Tui, info: var FullInfoRef, stats, live: var seq[StatV2]) = 508 | if tui.refresh: 509 | tui.reload = true 510 | 511 | if tui.reload: 512 | if tui.hist == 0: 513 | tui.blog = moveBlog(+1, tui.blog, stats.len, stats.len)[0] 514 | if tui.refresh: 515 | (info, stats) = hist(tui.hist, tui.blog, live, tui.forceLive) 516 | tui.refresh = false 517 | else: 518 | (info, stats) = histNoLive(tui.hist, tui.blog) 519 | tui.reload = false 520 | tui.draw = true 521 | 522 | if tui.draw: 523 | tui.redraw(info, stats, live) 524 | tui.draw = false 525 | 526 | iterator keyEachTimeout(refreshTimeout: int = 1000): Key = 527 | var timeout = refreshTimeout 528 | while true: 529 | let a = getMonoTime().ticks 530 | let k = getKeyWithTimeout(timeout) 531 | if k == Key.None: 532 | timeout = refreshTimeout 533 | else: 534 | let b = int((getMonoTime().ticks - a) div 1000000) 535 | timeout = timeout - (b mod refreshTimeout) 536 | yield k 537 | 538 | when defined(debug): 539 | import std/enumutils 540 | from terminal import setForegroundColor, setBackgroundColor, setStyle 541 | 542 | proc colors*() = 543 | var i = 0 544 | for gCurrBg in BackgroundColor: 545 | for gCurrFg in ForegroundColor: 546 | setForegroundColor(cast[terminal.ForegroundColor](fgNone)) 547 | setBackgroundColor(cast[terminal.BackgroundColor](bgNone)) 548 | # stdout.write fmt"{i:>3} {gCurrBg:>14}" 549 | stdout.write fmt"{i:>3} " 550 | setForegroundColor(cast[terminal.ForegroundColor](gCurrFg)) 551 | setBackgroundColor(cast[terminal.BackgroundColor](gCurrBg)) 552 | stdout.write fmt"{gCurrBg:>14} " 553 | for s in [{}, {styleBright}, {styleDim}]: 554 | setStyle(s) 555 | stdout.write fmt""" {($gCurrFg)[2..^1] & ($s).replace("style", "")[1..^2]:>14}""" 556 | setForegroundColor(cast[terminal.ForegroundColor](fgNone)) 557 | setBackgroundColor(cast[terminal.BackgroundColor](bgNone)) 558 | echo() 559 | inc i 560 | 561 | proc tui*() = 562 | init() 563 | illwillInit(fullscreen = true) 564 | defer: stopTui() 565 | setControlCHook(exitProc) 566 | hideCursor() 567 | 568 | if getCfg().light: 569 | fgColor = fgLightColor 570 | 571 | var tui = Tui(sort: Cpu) 572 | (tui.blog, tui.hist) = moveBlog(0, tui.blog, tui.hist, 0) 573 | var live = newSeq[StatV2]() 574 | var (info, stats) = hist(tui.hist, tui.blog, live, tui.forceLive) 575 | tui.redraw(info, stats, live) 576 | 577 | for key in keyEachTimeout(getCfg().refreshTimeout): 578 | tui.processKey(key, stats) 579 | if tui.quit: 580 | break 581 | tui.postProcess(info, stats, live) 582 | -------------------------------------------------------------------------------- /ttop.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "1.5.3" 4 | author = "inv2004" 5 | description = "Monitoring tool with historical snapshots and alerts" 6 | license = "MIT" 7 | srcDir = "src" 8 | bin = @["ttop"] 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 2.0.10" 13 | 14 | requires "https://github.com/inv2004/illwill" 15 | requires "zippy" 16 | requires "asciigraph" 17 | requires "parsetoml" 18 | requires "https://github.com/inv2004/jsony#non_quoted_key" 19 | 20 | const lmDir = "lm-sensors" 21 | 22 | task static, "build static release": 23 | exec "nim -d:release -d:NimblePkgVersion="&version&" --opt:size --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passC:-flto --passL:'-flto -static' -o:ttop c src/ttop.nim && strip -s ttop" 24 | 25 | task staticdebug, "build static debug": 26 | exec "nim -d:debug -d:NimblePkgVersion="&version&" --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -o:ttop-debug c src/ttop.nim" 27 | 28 | task bench, "bench": 29 | exec "nim -d:release --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -o:ttop c -r bench/bench.nim" 30 | --------------------------------------------------------------------------------