├── .gitignore ├── readme-img.png ├── modules ├── 20-uptime ├── 50-quote ├── 00-banner ├── 11-os ├── 13-public-ip ├── 30-load ├── 12-ip ├── 40-tmux ├── 41-updates ├── 34-services ├── 10-user ├── 31-temperatures ├── 33-disk ├── 35-docker ├── 37-smart ├── 36-raid └── 32-memory ├── config.sh.example ├── .github └── workflows │ └── lint.yml ├── motd.sh ├── LICENSE.md ├── README.md └── framework.sh /.gitignore: -------------------------------------------------------------------------------- 1 | config.sh 2 | -------------------------------------------------------------------------------- /readme-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcyran/fancy-motd/HEAD/readme-img.png -------------------------------------------------------------------------------- /modules/20-uptime: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | uptime=$(uptime -p | cut -d ' ' -f 2-) 8 | 9 | print_columns "Uptime" "${uptime}" 10 | -------------------------------------------------------------------------------- /modules/50-quote: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | quote="$(fortune -s | fold -sw "${WIDTH}")" 8 | 9 | print_columns "Quote" "${quote}" 10 | -------------------------------------------------------------------------------- /modules/00-banner: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | hostname=${HOSTNAME:-$(hostname)} 8 | 9 | print_columns "" "${CA}$(figlet "${hostname}")${CN}" 10 | -------------------------------------------------------------------------------- /modules/11-os: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | # shellcheck disable=SC1091 8 | source "/etc/os-release" 9 | 10 | print_columns "OS" "${NAME} ${VERSION:-}" 11 | -------------------------------------------------------------------------------- /config.sh.example: -------------------------------------------------------------------------------- 1 | # Colors 2 | CA="\e[34m" # Accent 3 | CO="\e[32m" # Ok 4 | CW="\e[33m" # Warning 5 | CE="\e[31m" # Error 6 | CN="\e[0m" # None 7 | 8 | # Max width used for components in second column 9 | WIDTH=50 10 | 11 | # Services to show 12 | declare -A services 13 | services["nginx"]="Nginx" 14 | services["docker"]="Docker" 15 | services["sshd"]="SSH" 16 | services["fail2ban"]="Fail2Ban" 17 | services["ufw"]="UFW" 18 | -------------------------------------------------------------------------------- /modules/13-public-ip: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | ip_v4=$(curl -4 ifconfig.me/ip) 8 | ip_v6=$(curl -6 ifconfig.me/ip) 9 | 10 | text4=$(print_wrap "${WIDTH}" "${ip_v4}") 11 | text6=$(print_wrap "${WIDTH}" "${ip_v6}") 12 | 13 | print_columns "Public IPv4 address" "${text4}" 14 | print_columns "Public IPv6 address" "${text6}" 15 | -------------------------------------------------------------------------------- /modules/30-load: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | loads=$(cut -d ' ' -f '1,2,3' /proc/loadavg) 8 | nproc=$(nproc) 9 | warning_threshold=$(bc -l <<< "${nproc} * 0.9") 10 | error_threshold=$(bc -l <<< "${nproc} * 1.5") 11 | 12 | text="" 13 | for load in ${loads}; do 14 | text+="$(print_color "${load}" "${load}" "${warning_threshold}" "${error_threshold}"), " 15 | done 16 | 17 | print_columns "Load average" "${text::-2}" 18 | -------------------------------------------------------------------------------- /modules/12-ip: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | ips_v4=$(ip a | awk '/inet / && /global/ {split($2, arr, /\//); print arr[1]}') 8 | IFS=$'\n' read -r -a ip4s <<< "${ips_v4}" 9 | 10 | ips_v6=$(ip a | awk '/inet6 / && /global/ {split($2, arr, /\//); print arr[1]}') 11 | IFS=$'\n' read -r -a ip6s <<< "${ips_v6}" 12 | 13 | text4=$(print_wrap "${WIDTH}" "${ip4s[@]}") 14 | text6=$(print_wrap "${WIDTH}" "${ip6s[@]}") 15 | 16 | print_columns "IPv4 addresses" "${text4}" 17 | print_columns "IPv6 addresses" "${text6}" 18 | -------------------------------------------------------------------------------- /modules/40-tmux: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | type tmux > /dev/null 2>&1 || exit 1 8 | 9 | set +e 10 | sessions=$(tmux ls 2>&1) 11 | tmux_exit="$?" 12 | set -e 13 | 14 | text="" 15 | if [[ ${tmux_exit} -ne 0 ]]; then 16 | text+="no sessions\n" 17 | else 18 | while IFS= read -r line; do 19 | name=$(cut -d ':' -f 1 <<< "${line}") 20 | windows=$(cut -d ' ' -f 2,3 <<< "${line}") 21 | date=$(cut -d ' ' -f 5,6,7,8 <<< "${line}") 22 | text+="$(print_split "${WIDTH}" "${name} (${windows})" "${date}")\n" 23 | done <<< "${sessions}" 24 | fi 25 | 26 | print_columns "Tmux sessions" "${text::-2}" 27 | -------------------------------------------------------------------------------- /modules/41-updates: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | if type checkupdates > /dev/null 2>&1; then 8 | updates=$(checkupdates 2> /dev/null | wc -l) 9 | if type yay > /dev/null 2>&1; then 10 | updates_aur=$(yay -Qum 2> /dev/null | wc -l) 11 | updates=$((updates + updates_aur)) 12 | fi 13 | elif type dnf > /dev/null 2>&1; then 14 | updates=$(dnf check-update --quiet | grep -c -v "^$") 15 | elif type apt > /dev/null 2>&1; then 16 | updates=$(apt list --upgradable 2> /dev/null | grep -c upgradable) 17 | else 18 | updates="N/A" 19 | fi 20 | 21 | text="$(print_color "${updates} available" "${updates}" 1 50)" 22 | 23 | print_columns "Updates" "${text}" 24 | -------------------------------------------------------------------------------- /modules/34-services: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Default services, have to be declared before sourcing framework.sh so the user can override them 6 | declare -A services 7 | services["nginx"]="Nginx" 8 | services["docker"]="Docker" 9 | services["sshd"]="SSH" 10 | services["fail2ban"]="Fail2Ban" 11 | services["ufw"]="UFW" 12 | 13 | # shellcheck source=./framework.sh 14 | source "${BASE_DIR}/framework.sh" 15 | 16 | statuses=() 17 | for key in "${!services[@]}"; do 18 | if [[ $(systemctl list-unit-files "${key}*" | wc -l) -gt 3 ]]; then 19 | status=$(systemctl show -p ActiveState --value "${key}") 20 | statuses+=("$(print_status "${services[${key}]}" "${status}")") 21 | fi 22 | done 23 | 24 | text=$(print_wrap "${WIDTH}" "${statuses[@]}") 25 | 26 | print_columns "Services" "${text}" 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | shellcheck: 13 | name: Shellcheck 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Run ShellCheck 18 | uses: ludeeus/action-shellcheck@master 19 | env: 20 | SHELLCHECK_OPTS: --enable avoid-nullary-conditions,require-variable-braces,require-double-brackets --external-sources 21 | with: 22 | additional_files: modules/* 23 | check_together: yes 24 | shfmt: 25 | name: shfmt 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: mfinelli/setup-shfmt@v1 30 | - run: shfmt -i 4 -bn -ci -sr -d motd.sh framework.sh modules/* 31 | -------------------------------------------------------------------------------- /modules/10-user: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | user=${USER:-$(id -un)} 8 | hostname=${HOSTNAME:-$(hostname)} 9 | usercount=$(users | wc -w) 10 | 11 | # Create array of users with IPs 12 | users=() 13 | while IFS= read -r line; do 14 | username=$(echo "${line}" | awk '{print $1}') 15 | # Extract IP from parentheses, regardless of column position 16 | ip=$(echo "${line}" | grep -oE '\([^)]+\)' | sed 's/[()]//g') 17 | if [[ -n "${ip}" ]]; then 18 | users+=("${username} (${ip})") 19 | else 20 | users+=("${username}") 21 | fi 22 | done < <(who) 23 | 24 | print_columns "Logged as" "${user}@${hostname}" 25 | if [[ ${#users[@]} -gt 0 ]]; then 26 | print_columns "Users (${usercount})" "$(print_wrap "${users[@]}")" 27 | else 28 | print_columns "Users (${usercount})" "" 29 | fi 30 | -------------------------------------------------------------------------------- /motd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Don't change! We want predictable outputs 4 | export LANG="en_US.UTF-8" 5 | 6 | # Dir of this scrip 7 | BASE_DIR=$(dirname "$(readlink -f "$0")") 8 | export BASE_DIR 9 | 10 | # Set config path 11 | if [[ -z ${1+x} ]]; then 12 | export CONFIG_PATH="${BASE_DIR}/config.sh" 13 | else 14 | export CONFIG_PATH="$1" 15 | fi 16 | 17 | # Source the framework 18 | source "${BASE_DIR}/framework.sh" 19 | 20 | # Run the modules and collect output 21 | output="" 22 | # shellcheck disable=SC2010 23 | modules="$(ls -1 "${BASE_DIR}/modules" | grep -P '^(? /dev/null); then continue; fi 26 | output+="${module_output}" 27 | [[ -n "${module_output}" ]] && output+=$'\n' 28 | done <<< "${modules}" 29 | 30 | # Print the output in pretty columns 31 | columnize "${output}" $'\t' $'\n' 32 | -------------------------------------------------------------------------------- /modules/31-temperatures: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | temp_intel() { 8 | local cores out 9 | 10 | cores="$(echo "${sensors_output}" | grep Core | awk '{printf "%s ", $3} {printf "%s %s %s\n", $6, $9, $12}' | tr -d '+°C,)')" 11 | out="" 12 | 13 | while IFS= read -r line; do 14 | IFS=" " read -r current high critical <<< "${line}" 15 | 16 | if [[ "${high}" == "${critical}" ]]; then 17 | high=$(bc -l <<< "${high} - 20") 18 | fi 19 | 20 | out+="$(print_color "${current}°C" "${current}" "${high}" "${critical}"), " 21 | done <<< "${cores}" 22 | 23 | echo "${out::-2}" 24 | } 25 | 26 | temp_amd() { 27 | echo "${sensors_output}" | awk '/Tdie/ {print $2}' | tr -d '+' 28 | } 29 | 30 | sensors_output="$(sensors)" 31 | 32 | if grep -q 'Tdie' <<< "${sensors_output}"; then 33 | temps=$(temp_amd) 34 | else 35 | temps=$(temp_intel) 36 | fi 37 | 38 | print_columns "Temperatures" "${temps}" 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jack Johannesen 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 | -------------------------------------------------------------------------------- /modules/33-disk: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | excluded_types=( 8 | "devtmpfs" 9 | "ecryptfs" 10 | "squashfs" 11 | "tmpfs" 12 | ) 13 | 14 | # shellcheck disable=SC2046 15 | disks="$(df -h --local --print-type $(printf " -x %s" "${excluded_types[@]}") | tail -n +2 | sort -u -k 7)" 16 | 17 | text="" 18 | while IFS= read -r disk; do 19 | IFS=" " read -r filesystem _ total used free percentage mountpoint <<< "${disk}" 20 | 21 | device=$(sed 's|/dev||g;s|/mapper||g;s|^/||g' <<< "${filesystem}") 22 | left_label="${device} () - ${used} used, ${free} free" 23 | right_label="/ ${total}" 24 | free_width=$((WIDTH - ${#left_label} - ${#right_label} - 1)) 25 | mountpoint=$(print_truncate "${mountpoint}" ${free_width} "start") 26 | left_label="${device} (${mountpoint}) - ${used} used, ${free} free" 27 | 28 | label=$(print_split "${WIDTH}" "${left_label}" "${right_label}") 29 | text+="${label}\n$(print_bar "${WIDTH}" "${percentage::-1}")\n" 30 | done <<< "${disks}" 31 | 32 | print_columns "Disk space" "${text::-2}" 33 | -------------------------------------------------------------------------------- /modules/35-docker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | type docker > /dev/null 2>&1 || exit 1 8 | 9 | # Get docker container name, status, uptime and compose stack 10 | containers=$(docker ps -a --format "{{ .Label \"com.docker.compose.project\" }},{{ .Names }},{{ .Status }},{{ .State }}") 11 | 12 | text="" 13 | if [[ -z "${containers}" ]]; then 14 | text+="no containers\n" 15 | else 16 | while IFS= read -r line; do 17 | IFS=$',' read -r stack name description state <<< "${line}" 18 | case ${state} in 19 | running) color="${CO}" ;; 20 | paused | restarting) color="${CW}" ;; 21 | exited | dead) color="${CE}" ;; 22 | *) color="${CN}" ;; 23 | esac 24 | 25 | # If stack is empty, it means the container is not part of a Docker Compose stack 26 | if [[ -n "${stack}" ]]; then 27 | name="${stack}.${name}" 28 | fi 29 | 30 | text+="$(print_split "${WIDTH}" "${name}" "${color}${description,,}${CN}")\n" 31 | done <<< "${containers}" 32 | fi 33 | 34 | print_columns "Docker" "${text::-2}" 35 | -------------------------------------------------------------------------------- /modules/37-smart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | type smartctl > /dev/null 2>&1 || exit 1 8 | # Check if the user can execute smartctl with sudo without any password 9 | sudo -n smartctl --version > /dev/null 2>&1 || exit 1 10 | 11 | # Disk list: 12 | disks=$(lsblk | awk '($6=="disk") {print $1}') 13 | 14 | statuses=() 15 | for disk in ${disks}; do 16 | # Avoid getting an error if disk doesn't work with smart 17 | smart_result=$(sudo smartctl -H "/dev/${disk}" 2> /dev/null || true) 18 | smart_value="${CW}unavailable${CN}" 19 | 20 | if echo "${smart_result}" | grep -q '^SMART overall\|^SMART Health Status'; then 21 | smart_test=$(echo "${smart_result}" | grep '^SMART overall\|^SMART Health Status' | rev | cut -d ' ' -f1 | rev) 22 | 23 | if [[ "${smart_test}" == 'PASSED' ]]; then 24 | smart_value="${CO}${smart_test}${CN}" 25 | else 26 | smart_value="${CE}${smart_test}${CN}" 27 | fi 28 | fi 29 | 30 | statuses+=("${disk} ${smart_value}") 31 | done 32 | 33 | text=$(print_wrap "${WIDTH}" "${statuses[@]}") 34 | 35 | print_columns "S.M.A.R.T." "${text}" 36 | -------------------------------------------------------------------------------- /modules/36-raid: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | grep "^md" /proc/mdstat > /dev/null 2>&1 || exit 1 8 | 9 | # This awk script produce output like this from /proc/mdstat 10 | # "" 11 | # examples : 12 | # md127 active raid5 sde1[2],sdc1[4],sdb1[0],sdd1[1] no_error 13 | # md0 inactive raid5 "sde1[2] sdc1[4]" error 14 | # shellcheck disable=SC2016 15 | awk_script='BEGIN { 16 | line = ""; 17 | } 18 | length(line) != 0 { 19 | error = "no_error"; 20 | if ($NF ~ /_/) { 21 | error = "error"; 22 | }; 23 | printf "%s %s\n", line, error; 24 | line = ""; 25 | } 26 | $1 ~ /md/ { 27 | infos = sprintf("%s %s %s", $1, $3, $4); 28 | devices = ""; 29 | for (i=5; i <= NF ; i++) { 30 | if (devices == "") 31 | devices = $i; 32 | else 33 | devices = sprintf("%s,%s", devices, $i); 34 | } 35 | line = sprintf("%s %s", infos, devices); 36 | }' 37 | 38 | raid_text=$(awk "${awk_script}" /proc/mdstat) 39 | 40 | IFS=" " read -r md_name status format devices errors <<< "${raid_text}" 41 | label_name=$(print_status "${md_name}" "${status}") 42 | if [[ ${errors} == 'errors' ]]; then 43 | label_error="${CE}ERROR${CN}" 44 | else 45 | label_error="${CO}OK${CN}" 46 | fi 47 | 48 | label="${label_name} ${CA}${format}${CN} (${devices}) ${label_error}" 49 | 50 | print_columns "RAID" "${label}" 51 | -------------------------------------------------------------------------------- /modules/32-memory: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | # shellcheck source=./framework.sh 5 | source "${BASE_DIR}/framework.sh" 6 | 7 | freeh=$(free -h) 8 | freem=$(free -m) 9 | meminfo=$(cat /proc/meminfo) 10 | 11 | if [[ -d /proc/spl/kstat/zfs ]]; then 12 | # if zfs is in use on system 13 | arcstat=$(cat /proc/spl/kstat/zfs/arcstats) 14 | 15 | # convert units to kb for easy calculation with /proc/meminfo 16 | arc_current=$(awk '/^size/ { OFMT="%.0f"; print $3/1024 }' <<< "${arcstat}") 17 | arc_min=$(awk '/^c_min/ { OFMT="%.0f"; print $3/1024 }' <<< "${arcstat}") 18 | 19 | # zfs arc size is dynamic, but can't shrink below the min size 20 | arcsize=$(bc <<< "${arc_current}-${arc_min}") 21 | else 22 | # if zfs isn't in use, set the arc to 0 23 | arcsize=0 24 | fi 25 | 26 | ram() { 27 | local availmem usedmem totalmem used avail total label percentage bar 28 | 29 | availmem=$(awk -v arcsize="${arcsize}" '/^MemAvailable:/ { print $2 + arcsize }' <<< "${meminfo}") 30 | usedmem=$(awk -v availmem="${availmem}" '/^MemTotal:/ { print $2 - availmem }' <<< "${meminfo}") 31 | totalmem=$(awk '/^MemTotal:/ { print $2 }' <<< "${meminfo}") 32 | 33 | # label display section 34 | used="$(numfmt --round=down --from-unit=1024 --to=iec <<< "${usedmem}")" 35 | avail="$(numfmt --round=down --from-unit=1024 --to=iec <<< "${availmem}")" 36 | total="$(numfmt --round=down --from-unit=1024 --to=iec <<< "${totalmem}")" 37 | label=$(print_split "${WIDTH}" "RAM - ${used} used, ${avail} available" "/ ${total}") 38 | 39 | # bar display section 40 | percentage=$(echo "${usedmem} / ${totalmem} * 100" | bc -l | xargs printf %.0f) 41 | bar=$(print_bar "${WIDTH}" "${percentage}") 42 | 43 | printf "%s\n%s" "${label}" "${bar}" 44 | } 45 | 46 | swap() { 47 | local swap total used available label percentage bar 48 | 49 | # Return if no swap 50 | [[ "$(awk '/Swap/ {print $2}' <<< "${freem}")" == 0 ]] && return 51 | 52 | swap=$(awk '/Swap/ {print $2,$3,$4}' <<< "${freeh}") 53 | IFS=" " read -r total used available <<< "${swap}" 54 | label=$(print_split "${WIDTH}" "Swap - ${used::-1} used, ${available::-1} available" "/ ${total::-1}") 55 | 56 | percentage=$(awk '/Swap/ {printf "%.0f", $3/$2*100}' <<< "${freem}") 57 | bar=$(print_bar "${WIDTH}" "${percentage}") 58 | 59 | printf "%s\n%s" "${label}" "${bar}" 60 | } 61 | 62 | out=$(ram) 63 | swap=$(swap) 64 | [[ -n "${swap}" ]] && out+="\n${swap}" 65 | 66 | print_columns "Memory" "${out}" 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fancy MOTD 2 | Fancy, colorful MOTD written in bash. Server status at a glance. 3 | 4 | ![MOTD screenshot](readme-img.png) 5 | 6 | ## Usage 7 | 8 | ### Running 9 | Clone the repository: 10 | ```shell 11 | git clone https://github.com/bcyran/fancy-motd.git 12 | ``` 13 | 14 | Then run `motd.sh`: 15 | ```shell 16 | ./fancy-motd/motd.sh 17 | ``` 18 | 19 | This runs all the scripts in `modules` directory in order, `run-parts` style, and formats the output. 20 | 21 | If any modules are missing in your output, plese see [requirements](#requirements). 22 | 23 | You can also pass the config file path as the script argument (see [configuration](#configuration)): 24 | ```shell 25 | ./fancy-motd/motd.sh ./path/to/config.sh 26 | ``` 27 | 28 | ### Running at login 29 | One way to run it at each login is to add a line to `~/.profile` file (assuming you cloned `fancy-motd` into your home directory): 30 | ```shell 31 | ~/fancy-motd/motd.sh 32 | ``` 33 | 34 | If you don't want to run it in all subshells you could do something like this instead: 35 | ```shell 36 | if [ -z "$FANCY_MOTD" ]; then 37 | ~/fancy-motd/motd.sh 38 | export FANCY_MOTD=1 39 | fi 40 | ``` 41 | 42 | If you use `tmux` and don't want to see the motd everytime you open a new shell in `tmux`, add this to your `.tmux.conf`: 43 | ``` 44 | set-option -ga update-environment ' FANCY_MOTD' 45 | ``` 46 | 47 | ### Requirements 48 | In order to run all the available modules the following programs are required: 49 | 50 | * [`figlet`](http://www.figlet.org/) 51 | * [`curl`](https://curl.se/) 52 | * [`bc`](https://www.gnu.org/software/bc/) 53 | * [`fortune`](https://software.clapper.org/fortune/) 54 | * [`lm-sensors`](https://github.com/lm-sensors/lm-sensors) 55 | 56 | This list excludes the obvious ones, like [`tmux`](https://github.com/tmux/tmux) for `tmux` module. 57 | 58 | If any program requried by the given module is missing (or any other error occurs), it will fail silently, i.e. the module just won't be shown at all. 59 | 60 | 61 | ### Configuration 62 | You can configure some aspects of the motd using config file. 63 | By default `config.sh` file in the `fancy-motd` directory will be read if it exists. 64 | Alternatively you can pass path to another config as a script argument. 65 | 66 | There's an example file provided in the repo: 67 | ```shell 68 | cd fancy-motd 69 | cp config.sh.example config.sh 70 | ``` 71 | 72 | ## Hacking 73 | To add a new module you can create a new script in `modules` directory. 74 | For the output to be properly formatted it has to use `print_columns` function from `framework.sh`, please refer to the existing modules. 75 | 76 | Module files have to start with a two digit number followed by a hyphen. You may disable modules by simply rename the module file. 77 | 78 | ## Credits 79 | Fancy MOTD is hugely inspired by [this repo](https://github.com/HermannBjorgvin/MOTD) by Hermann Björgvin. 80 | -------------------------------------------------------------------------------- /framework.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | # Source the config 4 | # shellcheck source=config.sh.example 5 | source "${CONFIG_PATH}" 6 | 7 | # Provide default values for obligatory settings 8 | # Colors 9 | CA="${CA:-\e[34m}" # Accent 10 | CO="${CO:-\e[32m}" # Ok 11 | CW="${CW:-\e[33m}" # Warning 12 | CE="${CE:-\e[31m}" # Error 13 | CN="${CN:-\e[0m}" # None 14 | 15 | # Max width used for components in second column 16 | WIDTH="${WIDTH:-50}" 17 | 18 | # Prints given blocks of text side by side 19 | # $1 - left column 20 | # $2 - right column 21 | print_columns() { 22 | [[ -z $2 ]] && return 23 | paste <(echo -e "${CA}$1${1:+:}${CN}") <(echo -e "$2") 24 | } 25 | 26 | # Prints given text n times 27 | # $1 - text to print 28 | # $2 - how many times to print 29 | print_n() { 30 | local out="" 31 | for ((i = 0; i < $2; i++)); do 32 | out+="$1" 33 | done 34 | echo "${out}" 35 | } 36 | 37 | # Prints bar divided in two parts by given percentage 38 | # $1 - bar width 39 | # $2 - percentage 40 | print_bar() { 41 | local bar_width=$(($1 - 2)) 42 | local used_width=$(($2 * bar_width / 100)) 43 | local free_width=$((bar_width - used_width)) 44 | local out="" 45 | out+="[" 46 | out+="${CE}" 47 | out+=$(print_n "=" ${used_width}) 48 | out+="${CO}" 49 | out+=$(print_n "=" ${free_width}) 50 | out+="${CN}" 51 | out+="]" 52 | echo "${out}" 53 | } 54 | 55 | # Prints text with color according to given value and two thresholds 56 | # $1 - text to print 57 | # $2 - current value 58 | # $3 - warning threshold 59 | # $4 - error threshold 60 | print_color() { 61 | local out="" 62 | if (($(bc -l <<< "$2 < $3"))); then 63 | out+="${CO}" 64 | elif (($(bc -l <<< "$2 >= $3 && $2 < $4"))); then 65 | out+="${CW}" 66 | else 67 | out+="${CE}" 68 | fi 69 | out+="$1${CN}" 70 | echo "${out}" 71 | } 72 | 73 | # Prints text as either acitve or inactive 74 | # $1 - text to print 75 | # $2 - literal "active" or "inactive" 76 | print_status() { 77 | local out="" 78 | if [[ $2 == "active" ]]; then 79 | out+="${CO}▲${CN}" 80 | else 81 | out+="${CE}▼${CN}" 82 | fi 83 | out+=" $1${CN}" 84 | echo "${out}" 85 | } 86 | 87 | # Prints comma-separated arguments wrapped to the given width 88 | # $1 - width to wrap to 89 | # $2, $3, ... - values to print 90 | print_wrap() { 91 | local width=$1 92 | shift 93 | local out="" 94 | local line_length=0 95 | for element in "$@"; do 96 | element="${element}," 97 | local visible_elelement future_length 98 | visible_elelement=$(strip_ansi "${element}") 99 | future_length=$((line_length + ${#visible_elelement})) 100 | if [[ ${line_length} -ne 0 && ${future_length} -gt ${width} ]]; then 101 | out+="\n" 102 | line_length=0 103 | fi 104 | out+="${element} " 105 | line_length=$((line_length + ${#visible_elelement})) 106 | done 107 | [[ -n "${out}" ]] && echo "${out::-2}" 108 | } 109 | 110 | # Prints some text justified to left and some justified to right 111 | # $1 - total width 112 | # $2 - left text 113 | # $3 - right text 114 | print_split() { 115 | local visible_first visible_second invisible_first_width invisible_second_width total_width \ 116 | first_half_width second_half_width format_string 117 | 118 | visible_first=$(strip_ansi "$2") 119 | visible_second=$(strip_ansi "$3") 120 | invisible_first_width=$((${#2} - ${#visible_first})) 121 | invisible_second_width=$((${#3} - ${#visible_second})) 122 | total_width=$(($1 + invisible_first_width + invisible_second_width)) 123 | 124 | if ((${#visible_first} + ${#visible_second} < $1)); then 125 | first_half_width=${#2} 126 | else 127 | first_half_width=$(($1 / 2)) 128 | fi 129 | second_half_width=$((total_width - first_half_width)) 130 | 131 | format_string="%-${first_half_width}s%${second_half_width}s" 132 | # shellcheck disable=SC2059 133 | printf "${format_string}" "${2:0:${first_half_width}}" "${3:0:${second_half_width}}" 134 | } 135 | 136 | # Prints one line of text, truncates it at specified width and add ellipsis. 137 | # Truncation can occur either at the start or at the end of the string. 138 | # $1 - line to print 139 | # $2 - width limit 140 | # $3 - "start" or "end", default "end" 141 | print_truncate() { 142 | local out 143 | local new_length=$(($2 - 1)) 144 | # Just echo the string if it's shorter than the limit 145 | if [[ ${#1} -le "$2" ]]; then 146 | out="$1" 147 | elif [[ -z "$3" || "$3" == "end" ]]; then 148 | out="${1::${new_length}}…" 149 | else 150 | out="…${1: -${new_length}}" 151 | fi 152 | echo "${out}" 153 | } 154 | 155 | # Strips ANSI color codes from given string 156 | # $1 - text to strip 157 | strip_ansi() { 158 | echo -e "$1" | sed -r "s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g" 159 | } 160 | 161 | # Following is basically simple `column` reimplementation because it doesn't work consistently. 162 | # I fucked way too long with this. 163 | # $1 - text to columnize 164 | # $2 - column separator 165 | # $3 - row separator 166 | columnize() { 167 | local left_lines left_widths right_lines max_left_width left right visible_left \ 168 | padding_width padding 169 | left_lines=() # Lines in left column 170 | left_widths=() # Numbers of visible chars in left lines 171 | right_lines=() # Lines in right column 172 | max_left_width=0 # Max width of left column line 173 | # Iterate over lines and populate above variables 174 | while IFS="$3" read -r line; do 175 | left="$(echo -e "${line}" | cut -d "$2" -f 1)" 176 | right="$(echo -e "${line}" | cut -d "$2" -f 2)" 177 | left_lines+=("${left}") 178 | right_lines+=("${right}") 179 | visible_left=$(strip_ansi "${left}") 180 | left_widths+=(${#visible_left}) 181 | [[ ${#visible_left} -gt ${max_left_width} ]] && max_left_width=${#visible_left} 182 | done <<< "$1" 183 | 184 | # Iterate over lines and print them while padding left column with spaces 185 | for ((i = 0; i < ${#left_lines[@]} - 1; i++)); do 186 | padding_width=$((max_left_width - left_widths[i])) 187 | padding=$(print_n " " ${padding_width}) 188 | echo -e "${left_lines[${i}]}${padding} ${right_lines[${i}]}" 189 | done 190 | } 191 | --------------------------------------------------------------------------------