В статье я рассказываю, как из набора команд (free, df, ip, ps, uptime) собрать CLI‑утилиту на Bash, добавить аргументы, цветовую индикацию и упаковать всё в AUR как пакет system-monitor

Я хотел посмотреть, насколько далеко я смогу продвинуть чистый Bash, прежде чем он рухнет под собственным синтаксисом. Поэтому, естественно, я решил написать инструмент мониторинга системы — на Bash.


Идея

Я хотел сделать скрипт, который показывает:

  • Загрузку процессора и количество ядер;

  • Использование оперативной памяти и процент;

  • Дисковое пространство для /;

  • Сетевой трафик (RX/TX);

  • Количество процессов.

По сути, «набор инструментов ленивого администратора Linux» в одном файле.

./system-monitor.sh

==== SYSTEM MONITOR ====  
Time: Вт 30 дек 2025 18:31:05 MSK  
Uptime: 5 days, 1 hour, 12 minutes  
  
==== CPU LOAD ====  
CPU Cores: 4  
Load Average: 1min: 2.91 | 5min: 1.89 | 15min: 1.50  
System Load: 72%  
  
==== MEMORY USAGE ====  
Total: 7.44 GB  
Used: 4.10 GB  
Available: 3.34 GB  
Usage: 55.1%  
  
==== DISK SPACE ====  
Filesystem: /dev/sda1 (/)  
Used: 159.3 GB  
Available: 138.2 GB  
Usage: 54%  
  
==== NETWORK STATISTICS ====  
Interface: wlan0  
Received: 8943.44 MB  
Transmitted: 1185.45 MB  
  
==== PROCESSES ====  
Total Processes: 281

И так мы получаем краткую сводку о системе, которая представляет перед собой комбинацию обычных команд для диагностики системы: ip, df, free,top, uptime.


Особенности

Я решил сделать не просто скрипт, который просто выводит текст на терминале. В тот скрипт было решено сдeлать следующее:

  • Аргументы командной строки (--help, --brief, --no-color, --version, -i);

  • Цветной вывод (для предупреждений);

  • Режим «непрерывного» мониторинга;

  • Безопасное завершение скрипта (Ctrl+C);

  • Проверки зависимостей.


Как это устроено внутри

CPU: Работаем с Load Average

Для процессора я решил не вычислять мгновенную загрузку (которая скачет каждую секунду), а брать средние значения из /proc/loadavg.

# --- CPU Metrics Collection ---
get_cpu_metrics() {
    local loadavg
    loadavg=$(cat /proc/loadavg 2>/dev/null)
    if [ -n "$loadavg" ]; then
        CPU_LOAD_1MIN=$(echo "$loadavg" | awk '{print $1}')
        CPU_LOAD_5MIN=$(echo "$loadavg" | awk '{print $2}')
        CPU_LOAD_15MIN=$(echo "$loadavg" | awk '{print $3}')
        CPU_CORES=$(nproc 2>/dev/null || echo "1")

        # Calculate load as percentage of CPU cores
        local load_percent
        load_percent=$(echo "scale=0; ($CPU_LOAD_1MIN * 100) / $CPU_CORES" | bc 2>/dev/null)
        CPU_LOAD_PERCENT=${load_percent:-0}
    else
        CPU_LOAD_1MIN="N/A"
        CPU_LOAD_5MIN="N/A"
        CPU_LOAD_15MIN="N/A"
        CPU_LOAD_PERCENT="N/A"
        CPU_CORES="N/A"
    fi
}

Примечание:

  • Тут используется nproc, чтобы понимать масштаб трагедии (Load Average 2.0 на 1 ядре и на 16 ядрах — это всё-таки разные вещи);

  • Математика происходит через bc, так как нужно умножать и делить значения с плавающей точкой из loadavg.

Да, зависимость от bc — компромисс. Я предпочёл читаемость и предсказуемость арифметики попыткам эмулировать float-математику в чистом Bash.


Колдуем над free

С памятью, конечно, ситуация чуть сложнее. Я использую free -b (в байтах), чтобы получить максимально точные цифры, а потом перевожу их в ГБ для читаемости.

# --- Memory Metrics Collection ---
get_memory_metrics() {
    local mem_info
    mem_info=$(free -b 2>/dev/null | grep Mem)
    if [ -n "$mem_info" ]; then
        MEM_TOTAL=$(echo "$mem_info" | awk '{printf "%.2f", $2/1024/1024/1024}')
        MEM_USED=$(echo "$mem_info" | awk '{printf "%.2f", $3/1024/1024/1024}')
        MEM_AVAILABLE=$(echo "$mem_info" | awk '{printf "%.2f", $7/1024/1024/1024}')
        MEM_PERCENT=$(echo "scale=1; ($MEM_USED * 100) / $MEM_TOTAL" | bc 2>/dev/null)
    else
        MEM_TOTAL="N/A"
        MEM_USED="N/A"
        MEM_AVAILABLE="N/A"
        MEM_PERCENT="N/A"
    fi
}

awk — древнее проклятие. Это всегда работает, но никогда не по той причине, которую вы ожидаете.


Сеть: Динамический поиск интерфейса

Чтобы не заставлять пользователя вводить имя сетевой карты, скрипт сам ищет «точку выхода» в интернет.

# --- Network Metrics Collection ---
get_network_metrics() {
    # Get primary network interface (route to Google DNS)
    local interface
    interface=$(ip route get 8.8.8.8 2>/dev/null | awk '{print $5}' | head -1)
    if [ -n "$interface" ] && [ "$interface" != "dev" ]; then
        NET_INTERFACE="$interface"
        local rx_bytes
        rx_bytes=$(cat "/sys/class/net/$interface/statistics/rx_bytes" 2>/dev/null)
        local tx_bytes
        tx_bytes=$(cat "/sys/class/net/$interface/statistics/tx_bytes" 2>/dev/null)

        NET_RX_MB=$(echo "scale=2; ${rx_bytes:-0} / 1024 / 1024" | bc 2>/dev/null)
        NET_TX_MB=$(echo "scale=2; ${tx_bytes:-0} / 1024 / 1024" | bc 2>/dev/null)
    else
        NET_INTERFACE="N/A"
        NET_RX_MB="N/A"
        NET_TX_MB="N/A"
    fi
}

После того как интерфейс найден, мы идем в системные файлы /sys/class/net/$interface/statistics/. Это стандартный путь в Linux, где ядро хранит счетчики байтов rx_bytes (получено) и tx_bytes (отправлено).

Важное уточнение: скрипт показывает общий объем переданных данных (Traffic Total), а не текущую скорость. Для расчета скорости пришлось бы делать замеры с дельтой по времени, что усложнило бы логику, поэтому пока ограничился счетчиками


Диск и процессы

Для диска используется df -B1 /, что дает размер корневого раздела в байтах. Это позволяет избежать проблем с округлением, которые бывают при df -h. А количество процессов считается через ps -e | wc -l, что является самым быстрым способом подсчета всех активных PID в системе.

# --- Disk Metrics Collection ---
get_disk_metrics() {
    local disk_info
    disk_info=$(df -B1 / 2>/dev/null | awk 'NR==2')
    if [ -n "$disk_info" ]; then
        DISK_USED=$(echo "$disk_info" | awk '{printf "%.1f", $3/1024/1024/1024}')
        DISK_AVAILABLE=$(echo "$disk_info" | awk '{printf "%.1f", $4/1024/1024/1024}')
        DISK_PERCENT=$(echo "$disk_info" | awk '{print $5}' | sed 's/%//')
        DISK_FILESYSTEM=$(echo "$disk_info" | awk '{print $1}')
        DISK_MOUNT=$(echo "$disk_info" | awk '{print $6}')
    else
        DISK_USED="N/A"
        DISK_AVAILABLE="N/A"
        DISK_PERCENT="N/A"
        DISK_FILESYSTEM="N/A"
        DISK_MOUNT="N/A"
    fi
}

Работа с цветом и порогами

Чтобы реализовать индикацию в виде предупреждений или критических состояний я написал функцию colorize().

#--- COLOR FOR OUTPUT ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

# --- Colorize Output Based on Thresholds ---
colorize() {
    local value=$1
    local warning=$2
    local critical=$3

    #SKip coloring if disabled
    if [ "$USE_COLORS" = false ]; then
        echo "$value"
        return
    fi

    # Handle empty or N/A values
    if [ -z "$value" ] || [ "$value" = "N/A" ]; then
        echo -e "${YELLOW}N/A${NC}"
     # Check if value exceeds critical threshold (requires bc for float comparison)
    elif [ "$(echo "$value >= $critical" | bc 2>/dev/null)" = "1" ] 2>/dev/null; then
        echo -e "${RED}$value${NC}"
    # Check if value exceeds warning threshold
    elif [ "$(echo "$value >= $warning" | bc 2>/dev/null)" = "1" ] 2>/dev/null; then
        echo -e "${YELLOW}$value${NC}"
    else
        echo -e "${GREEN}$value${NC}"
    fi
}

Тут пришлось использовать bc, так как Bash не умеет сравнивать числа с плавающей запятой напрямую.


Обработка аргументов и «безопасный» выход

Чтобы скрипт не был «одноразовым», я добавил полноценный парсинг аргументов через case. Это позволяет комбинировать флаги, например, запустить мониторинг без цвета: ./system-monitor.sh -i 2 --no-color.

#--- Command Line Arguments Parser ---
parse_arguments() {
    while [[ $# -gt 0 ]]; do
        case $1 in
            -i)
                if [[ ! $2 =~ ^[0-9]+$ ]] || [ "$2" -lt 1 ]; then
                    echo -e "${RED}Error: Interval must be a positive integer${NC}"
                    exit 1
                fi
                INTERVAL=$2
                shift 2
                ;;
            --brief)
                BRIEF_MODE=true
                USE_COLORS=false
                shift
                ;;
            --no-color)
                USE_COLORS=false
                shift
                ;;
            --version)
                echo "${SCRIPT_NAME} version ${VERSION}"
                exit 0
                ;;
            --help)
                show_help
                exit 0
                ;;
            *)
                echo -e "${RED}Unknown argument: $1${NC}"
                show_help
                exit 1
                ;;
        esac
    done
}

Отдельное внимание уделил функции cleanup. Если вы запускаете бесконечный цикл мониторинга, важно, чтобы по нажатию Ctrl+C скрипт не просто обрывался, а корректно завершался. Для этого используется trap, который перехватывает сигнал SIGINT и выполняет финальный блок кода.

# --- Signal Handler for Clean Shutdown ---
cleanup() {
    EXIT_SIGNAL=true
    echo -e "\n${YELLOW}Monitoring stopped. Goodbye!${NC}"
    exit 0
}
trap cleanup INT TERM

Машиночитаемый вывод (он же brief-mode)

Иногда нужно не смотреть на экран глазами, а передать данные в Zabbix, Polybar, Prometheus (или через что вы мониторите систему). Для этого я сделал режим --brief, который выводит «сырые» данные в формате key=value

# --- Machine-Readable Output Format ---
print_brief_stats() {
    get_cpu_metrics
    get_memory_metrics
    get_disk_metrics
    get_network_metrics
    get_process_metrics

    echo "timestamp=$(date +%s)"
    echo "cpu_load_1min=$CPU_LOAD_1MIN"
    echo "cpu_load_5min=$CPU_LOAD_5MIN"
    echo "cpu_load_15min=$CPU_LOAD_15MIN"
    echo "cpu_cores=$CPU_CORES"
    echo "cpu_load_percent=$CPU_LOAD_PERCENT"
    echo "mem_total_gb=$MEM_TOTAL"
    echo "mem_used_gb=$MEM_USED"
    echo "mem_available_gb=$MEM_AVAILABLE"
    echo "mem_usage_percent=$MEM_PERCENT"
    echo "disk_filesystem=$DISK_FILESYSTEM"
    echo "disk_mount=$DISK_MOUNT"
    echo "disk_used_gb=$DISK_USED"
    echo "disk_available_gb=$DISK_AVAILABLE"
    echo "disk_usage_percent=$DISK_PERCENT"
    echo "network_interface=$NET_INTERFACE"
    echo "network_rx_mb=$NET_RX_MB"
    echo "network_tx_mb=$NET_TX_MB"
    echo "process_count=$PROCESS_COUNT"
}

Пример вывод brief-mode

system-monitor --brief
timestamp=1767112871
cpu_load_1min=3.11
cpu_load_5min=2.27
cpu_load_15min=1.98
cpu_cores=4
cpu_load_percent=77
mem_total_gb=7.44
mem_used_gb=3.68
mem_available_gb=3.75
mem_usage_percent=49.4
disk_filesystem=/dev/sda1
disk_mount=/
disk_used_gb=159.3
disk_available_gb=138.2
disk_usage_percent=54
network_interface=wlan0
network_rx_mb=8961.65
network_tx_mb=1194.35
process_count=277

Превращаем скрипт в утилиту

После написания и тестирования скрипта хотелось бы его упаковать, чтобы скачать из репозитория. Поскольку я сам сижу на CachyOS (Based-on Arch), выбор пал на AUR (Arch User Repository). AUR — это уникальная штука. В отличие от репозиториев Debian или RHEL, куда «простому смертному» (то есть такие, как я) попасть сложно, AUR позволяет любому пользователю поделиться своим решением с сообществом.

С помощью PKGBUILD скрипт при установке переименовывается из system-monitor.sh в лаконичный system-monitor и кладется в /usr/bin/. Теперь мне не нужно помнить, в какой папке лежит файл, — он доступен из любой точки терминала. Я четко прописал depends (bash, gawk, procps-ng). Если кто-то решит поставить мой монитор на «голый» Arch, менеджер пакетов сам доставит всё необходимое.

В поле optdepends я вынес bc. Скрипт запустится и без него, но для высокой точности вычислений пакет менеджер предложит его доустановить.

При обновлении версии процесс стандартный: я создаю git-тег в репозитории, обновляю pkgver в PKGBUILD и пересчитываю sha256sums через makepkg -g. Это позволяет гарантировать, что пакет в AUR всегда соответствует конкретному релизу в GitHub, а не «плавающему» состоянию репозитория.

Пример PKGBUILD

# Maintainer: DanLin (DanLinX2004X on GitHub)
# Github repo: https://github.com/DanLinX2004X/system-monitor 
pkgname=system-monitor
pkgver=1.1.0
pkgrel=1
pkgdesc="A powerful Bash script for real-time system monitoring (CPU, memory, disk, network, processes)"
arch=('any')
url="https://github.com/DanLinX2004X/system-monitor"
license=('GPL3')
depends=('bash' 'coreutils' 'procps-ng' 'util-linux' 'grep' 'gawk')
optdepends=('bc: for precise calculations')
source=("$pkgname-$pkgver.tar.gz::https://github.com/DanLinX2004X/system-monitor/archive/v$pkgver.tar.gz")
sha256sums=('9587d15a6f0e6fc75f888f8fc91c54e823415fbb10de863f77174423cf220d46')

package() {
  cd "$srcdir/$pkgname-$pkgver"
  
  install -Dm755 system-monitor.sh "$pkgdir/usr/bin/system-monitor"
  install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
  install -Dm644 README.ru.md "$pkgdir/usr/share/doc/$pkgname/README.ru.md"
  install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

Выводы

В результате получилась легкая утилита без лишних зависимостей, которая закрывает 90% моих потребностей в быстром диагностировании системы.
Главный вывод: Bash всё ещё нормально подходит для CLI‑утилит, если не забывать про модульность, обработку ошибок и здравый смысл в выборе задач.
Если хотите потыкать скрипт или предложить свои улучшения — буду рад звездочкам и PR в репозитории.

Ссылки: