diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34da9a7fd9..543fa35091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,6 +199,19 @@ Below are minimal, copy-paste-ready examples that follow DietPi conventions. log=1 G_WHIP_VIEWFILE "$FP_LOG" || return ``` +- `G_TRUNCATE_MID` (shorten long strings by squishing the middle characters): + ``` + G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26 + # -> "Long text to... the middle" + + # And `G_TRUNCATE_MID "alphabetical" N` returns the below, as N decreases + # alp...al + # al...al + # alphab + # alpha + # ... and so on + ``` + - `Save()` persistence pattern (follow dietpi-banner conventions): ``` Save(){ diff --git a/dietpi/func/dietpi-banner b/dietpi/func/dietpi-banner index 2da3f34cfb..d61bac83a5 100755 --- a/dietpi/func/dietpi-banner +++ b/dietpi/func/dietpi-banner @@ -5,6 +5,7 @@ # #//////////////////////////////////// # Created by Daniel Knight / daniel.knight@dietpi.com / dietpi.com + # Updated May 2026 by Tim Olson / timjolson@users.noreply.github.com # #//////////////////////////////////// # @@ -39,63 +40,203 @@ # Globals #///////////////////////////////////////////////////////////////////////////////////// readonly FP_SAVEFILE='/boot/dietpi/.dietpi-banner' - readonly FP_CUSTOM='/boot/dietpi/.dietpi-banner_custom' readonly FP_BANNERWRAP_AWK='/boot/dietpi/func/dietpi-banner-wrap.awk' - aDESCRIPTION=( - - 'Device model' - 'Uptime' - 'CPU temp' - 'FQDN/hostname' - 'NIS domainname' - 'LAN IP' - 'WAN IP' - 'Disk usage (RootFS)' - 'Disk usage (userdata)' - 'Weather (wttr.in)' - 'Custom banner entry' - 'Display DietPi useful commands?' - 'MOTD' - 'VPN status' - 'Large hostname' - 'Print credits' - 'Let'\''s Encrypt cert status' - 'RAM usage' - 'Load average' - 'Word-wrap lines on small screens' - 'Kernel' + # Use associative arrays keyed by short names derived from the description + declare -A aDESCRIPTION=( + [device_model]='Device model' + [uptime]='Uptime' + [cpu_temp]='CPU temp' + [hostname]='FQDN/hostname' + [nis_domainname]='NIS domainname' + [lan_ip]='LAN IP' + [wan_ip]='WAN IP' + [weather]='Weather (wttr.in)' + [custom_commands]='Custom commands' + [dietpi_commands]='Display useful DietPi commands?' + [motd]='MOTD' + [vpn_status]='VPN status' + [large_hostname]='Large hostname' + [credits]='Print credits' + [letsencrypt]='Let'\''s Encrypt cert status' + [ram_usage]='RAM usage' + [load_average]='Load average' + [word_wrap]='Word-wrap lines on small screens' + [kernel]='Kernel' + [network_usage]='Network usage' + [disk_usage]='Disk usage' + [disk_patterns]='does not show in the menu. allows disk patterns to be enabled/disabled' + [systemd]='Systemd status' + [fail2ban]='Fail2Ban status' + ) + # Set the array order for the menu, items must be listed here to show up + MENU_ITEMS=( + large_hostname device_model uptime kernel cpu_temp ram_usage load_average hostname nis_domainname + lan_ip wan_ip vpn_status weather letsencrypt systemd fail2ban motd + network_usage disk_usage custom_commands dietpi_commands credits word_wrap ) - # Set defaults: Disable CPU temp by default in VMs - if (( $G_HW_MODEL == 20 )) - then - aENABLED=(1 0 0 0 0 1 0 1 0 0 0 1 1 0 0 1 0 0 0 0 0) - else - aENABLED=(1 0 1 0 0 1 0 0 0 0 0 1 1 0 0 1 0 0 0 0 0) - fi + # Initialize aENABLED from aDESCRIPTION keys + declare -A aENABLED + for k in "${!aDESCRIPTION[@]}" + do + aENABLED[$k]=0 + done - COLOUR_RESET='\e[0m' - aCOLOUR=( + # Set default options + aENABLED[device_model]=1 + aENABLED[lan_ip]=1 + aENABLED[disk_usage]=1 + aENABLED[dietpi_commands]=1 + aENABLED[motd]=1 + aENABLED[credits]=1 + # Enable CPU temp by default except in VMs + (( $G_HW_MODEL != 20 )) && aENABLED[cpu_temp]=1 - '\e[38;5;154m' # DietPi green | Lines, bullets and separators - '\e[1m' # Bold white | Main descriptions - '\e[90m' # Grey | Credits - '\e[91m' # Red | Update notifications + # Default colours + COLOUR_RESET='\e[0m' # Reset formatting to default (do not override this) + # Colour and format references: + # - `https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit` + # - `https://invisible-island.net/xterm/ctlseqs/ctlseqs.html` under section "Character Attributes (SGR)" + # - `https://www.ditig.com/256-colors-cheat-sheet` + # - `https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters` + declare -A aCOLOUR=( + [accent]='\e[38;5;154m' # DietPi green | Lines, bullets and separators + [strong]='\e[1m' # Bold white | Main descriptions + [weak]='\e[90m' # Grey | Subdued text + [alert]='\e[91m' # Red | Update notifications / Bad state + [good]='\e[1;32m' # Green | Good state + [highlight]='\e[1;33m' # Yellow | Warning state + [alt]='\e[1;36m' # Blue | Dynamic match ) # Folding type (colon, dash, fixed), and fixed indent BW_INDENT_TYPE='colon' BW_INDENT_FIXED=3 - # Load settings here, to have chosen ${aCOLOUR[0]} applied to below strings + # Set default custom patterns and commands. If the user has a legacy .dietpi-banner_custom file, + # it will be migrated into aCUSTOM_COMMANDS later. + # shellcheck disable=SC2016 + declare -A aCUSTOM_COMMANDS=() + + # Default disk mounts (explicit paths) and patterns (globs/regex) + aDISK_SPACE_MOUNTS=() + aDISK_SPACE_PATTERNS=() + + # Load settings here, to have chosen custom CLI colours applied # shellcheck disable=SC1090 [[ -f $FP_SAVEFILE ]] && . "$FP_SAVEFILE" - GREEN_LINE=" ${aCOLOUR[0]}─────────────────────────────────────────────────────$COLOUR_RESET" - GREEN_BULLET=" ${aCOLOUR[0]}-$COLOUR_RESET" - GREEN_SEPARATOR="${aCOLOUR[0]}:$COLOUR_RESET" + # Set aCUSTOM_COMMANDS_ORDER from the assignments in the prefs file. + # Runtime order of custom command keys (stores user-defined order for display, saving, and menu). + aCUSTOM_COMMANDS_ORDER=() + + if [[ -f $FP_SAVEFILE ]]; then + while IFS= read -r line; do + # match patterns like: aCUSTOM_COMMANDS['key']='value' OR aCUSTOM_COMMANDS[0]='value' + if [[ $line =~ ^aCUSTOM_COMMANDS\[\'([^\']+)\'\]= ]]; then + aCUSTOM_COMMANDS_ORDER+=("${BASH_REMATCH[1]}") + elif [[ $line =~ ^aCUSTOM_COMMANDS\[([0-9]+)\]= ]]; then + aCUSTOM_COMMANDS_ORDER+=("${BASH_REMATCH[1]}") + fi + done < "$FP_SAVEFILE" + fi + + # ---------------------------------------------------------------------------- + # Legacy config migration helper + # ---------------------------------------------------------------------------- + # Migration helper function + DIETPI_BANNER_LEGACY_MIGRATION() + { + # Migrate legacy numeric-indexed aENABLED (from older FP_SAVEFILE) to associative form + local k legacy_indexed_enabled_keys=( + device_model uptime cpu_temp hostname nis_domainname lan_ip wan_ip disk_rootfs disk_userdata + weather custom_commands dietpi_commands motd vpn_status large_hostname credits letsencrypt + ram_usage load_average word_wrap kernel network_usage disk_usage systemd fail2ban + ) + for k in "${!aENABLED[@]}" + do + # Leave non-numeric indices untouched + [[ $k =~ ^[0-9]+$ ]] || continue + + # Apply as new associative array index from legacy_indexed_enabled_keys if there is one + # shellcheck disable=SC2015 + [[ ${legacy_indexed_enabled_keys[k]} ]] && aENABLED[${legacy_indexed_enabled_keys[k]}]=${aENABLED[$k]} || continue + + # Unset old numeric index + unset -v "aENABLED[$k]" + + # Mark that a migration from legacy numeric indices occurred during this run + DIETPI_BANNER_MIGRATED_DURING_RUN=1 + done + + # Migrate legacy disk display options + if [[ -v aENABLED[disk_rootfs] ]] + then + if (( aENABLED[disk_rootfs] )) + then + aDISK_SPACE_MOUNTS+=('/') + aENABLED[disk_usage]=1 + fi + unset "aENABLED[disk_rootfs]" + DIETPI_BANNER_MIGRATED_DURING_RUN=1 + fi + if [[ -v aENABLED[disk_userdata] ]] + then + if (( aENABLED[disk_userdata] )) + then + aDISK_SPACE_MOUNTS+=('/mnt/dietpi_userdata') + aENABLED[disk_usage]=1 + fi + unset "aENABLED[disk_userdata]" + DIETPI_BANNER_MIGRATED_DURING_RUN=1 + fi + + # Migrate legacy custom command file to aCUSTOM_COMMANDS array if present + readonly FP_CUSTOM='/boot/dietpi/.dietpi-banner_custom' + if [[ -f $FP_CUSTOM ]] + then + migrated_command_key="migrated-$(date '+%Y-%m-%d_%H-%M-%S')" + # Add a custom command that runs the external file with bash + aCUSTOM_COMMANDS+=([$migrated_command_key]="bash \"$FP_CUSTOM\"") + aCUSTOM_COMMANDS_ORDER+=("$migrated_command_key") + DIETPI_BANNER_MIGRATED_CUSTOM_COMMAND=1 + fi + + # Migrate legacy numeric-indexed aCOLOUR (from older FP_SAVEFILE) to associative form + local legacy_indexed_colour_keys=(accent strong weak alert good highlight alt) + for k in "${!aCOLOUR[@]}" + do + # Leave non-numeric indices untouched + [[ $k =~ ^[0-9]+$ ]] || continue + + # Apply as new associative array index from legacy_indexed_colour_keys if there is one + # shellcheck disable=SC2015 + [[ ${legacy_indexed_colour_keys[k]} ]] && aCOLOUR[${legacy_indexed_colour_keys[k]}]=${aCOLOUR[$k]} || continue + + # Unset old numeric index + unset -v "aCOLOUR[$k]" + + # Mark that a migration from legacy numeric indices occurred during this run + DIETPI_BANNER_MIGRATED_DURING_RUN=1 + done + } + # Flag indicating whether a legacy numeric->associative config migration happened during this run + DIETPI_BANNER_MIGRATED_DURING_RUN=0 + DIETPI_BANNER_MIGRATED_CUSTOM_COMMAND=0 + # Run migration + DIETPI_BANNER_LEGACY_MIGRATION + # ---------------------------------------------------------------------------- + + # Derived convenience strings + CLI_LINE=" ${aCOLOUR[accent]}─────────────────────────────────────────────────────$COLOUR_RESET" + CLI_BULLET=" ${aCOLOUR[accent]}-$COLOUR_RESET" + CLI_SEPARATOR="${aCOLOUR[accent]}:$COLOUR_RESET" + + # IP address detection (IPv4) - mawk-friendly (avoid + and {m,n}) + IP4_re='[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*' + # DietPi version string DIETPI_VERSION="$G_DIETPI_VERSION_CORE.$G_DIETPI_VERSION_SUB.$G_DIETPI_VERSION_RC" if [[ $G_GITOWNER != 'MichaIng' ]] then @@ -154,19 +295,57 @@ Save() { - # Custom entry description - echo "aDESCRIPTION[10]='${aDESCRIPTION[10]}'" - + # Persist associative aENABLED as simple assignments (one per line) for i in "${!aDESCRIPTION[@]}" do echo "aENABLED[$i]=${aENABLED[$i]}" done + # Persist only the primary colour slots. Convert any actual + # ESC bytes to the two-character sequence \e so the saved file is + # portable when sourced in different shells. for i in "${!aCOLOUR[@]}" do - echo "aCOLOUR[$i]='${aCOLOUR[$i]}'" + val=${aCOLOUR[$i]} + # Replace actual ESC bytes (0x1b) with literal \e + esc=$(printf '%s' "$val" | sed $'s/\x1b/\\e/g') + # Escape single quotes for safe embedding + esc=${esc//\'/\\\'} + echo "aCOLOUR[$i]='$esc'" + done + + # Persist aDISK_SPACE_MOUNTS (explicit mount paths, indices 0..N) + for i in "${!aDISK_SPACE_MOUNTS[@]}" + do + mount=${aDISK_SPACE_MOUNTS[$i]} + mount=${mount//\'/\\\'} + echo "aDISK_SPACE_MOUNTS[$i]='$mount'" + done + + # Persist aDISK_SPACE_PATTERNS (glob/regex patterns, indices 0..N) + for i in "${!aDISK_SPACE_PATTERNS[@]}" + do + patt=${aDISK_SPACE_PATTERNS[$i]} + patt=${patt//\'/\\\'} + echo "aDISK_SPACE_PATTERNS[$i]='$patt'" done + # Persist aCUSTOM_COMMANDS (associative indices) in user-specified order + for i in "${aCUSTOM_COMMANDS_ORDER[@]}" + do + # Skip a transient migrated command + [[ $i == "$migrated_command_key" ]] && continue + # Skip empty commands + [[ -n "${aCUSTOM_COMMANDS[$i]:-}" ]] || continue + # Escape single quotes in the key and value for safe embedding + key=${i} + key=${key//\'/\\\'} + cmd=${aCUSTOM_COMMANDS[$i]} + cmd=${cmd//\'/\\\'} + echo "aCUSTOM_COMMANDS['$key']='$cmd'" + done + + # Persist BW indent settings echo "BW_INDENT_TYPE='$BW_INDENT_TYPE'" echo "BW_INDENT_FIXED=$BW_INDENT_FIXED" } @@ -176,57 +355,706 @@ # DietPi update available? if Check_DietPi_Update then - local text_update_available_date="${aCOLOUR[3]}Update available" + local text_update_available_date="${aCOLOUR[highlight]}Update available" # New DietPi live patches available? elif Check_DietPi_Live_Patches then - local text_update_available_date="${aCOLOUR[3]}New live patches available" + local text_update_available_date="${aCOLOUR[highlight]}New live patches available" # APT update available? elif Check_APT_Updates then - local text_update_available_date="${aCOLOUR[3]}$PACKAGE_COUNT APT updates available" + local text_update_available_date="${aCOLOUR[highlight]}$PACKAGE_COUNT APT updates available" # Reboot required to finalise kernel upgrade? elif Check_Reboot then - local text_update_available_date="${aCOLOUR[3]}Reboot required" + local text_update_available_date="${aCOLOUR[alert]}Reboot required" else local locale=$(sed -n '/^[[:blank:]]*AUTO_SETUP_LOCALE=/{s/^[^=]*=//p;q}' /boot/dietpi.txt) local text_update_available_date=$(LC_ALL=${locale:-C.UTF-8} date '+%R - %a %x') fi - echo -e "$GREEN_LINE - ${aCOLOUR[1]}DietPi v$DIETPI_VERSION$COLOUR_RESET $GREEN_SEPARATOR $text_update_available_date$COLOUR_RESET -$GREEN_LINE" + echo -e "$CLI_LINE + ${aCOLOUR[strong]}DietPi v$DIETPI_VERSION$COLOUR_RESET $CLI_SEPARATOR $text_update_available_date$COLOUR_RESET +$CLI_LINE" + } + + Print_Subheader() + { + # Output green line separator with subheader title "$1" + echo -e "$CLI_LINE + ${aCOLOUR[strong]}$1 $CLI_SEPARATOR" } - Print_Local_Ip() + Get_Local_Ip() { - (( ${aENABLED[5]} )) || return 0 local iface=$(G_GET_NET -q iface) local ip=$(G_GET_NET -q ip) - echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[5]} $GREEN_SEPARATOR ${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" + echo "${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" } - Print_Cert_Status() + Get_Cert_Status() { - # Let's Encrypt cert status - MUST be run as root - local i - for i in /etc/letsencrypt/live/*/cert.pem + Get_Cert_Status_Raw() + { + # helper function so that we can run it with sudo in non-root mode without needing to export all the above functions and variables. + # Keep it dash-compatible + local i + for i in /etc/letsencrypt/live/*/cert.pem + do + # shellcheck disable=SC2292 + [ -f "$i" ] || continue + openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' + return 0 + done + echo 'No certificate found' + } + + local stat code + if [[ $EUID == 0 ]] + then + # Running as root + stat=$(Get_Cert_Status_Raw 2>&1) + code=$? + else + # Running as non-root: Fail silently without NOPASSWD to avoid password prompt + stat=$(sudo -n dash -c "$(declare -f Get_Cert_Status_Raw); Get_Cert_Status_Raw 2>&1" 2> /dev/null) + code=$? + fi + + case $code in + 0) + # Get_Cert_Status_Raw executed, process output + case $stat in + 'Valid until'*) stat="${aCOLOUR[weak]}$stat";; # valid cert, print line with weak color + 'No certificate found') stat="${aCOLOUR[highlight]}$stat";; # highlight if there is no cert + *) stat="${aCOLOUR[alert]}$stat";; # alert if cert expired or there was another error + esac + ;; + *) + # sudo failed, print meaningful error message + stat="${aCOLOUR[highlight]}NOPASSWD sudo required to obtain cert status" + ;; + esac + + echo "$stat" + } + + Get_Systemd_Status() + { + # Print systemd overall status based on "systemctl --failed" + FAILED_CNT=$(systemctl --failed --no-legend --no-pager | wc -l 2> /dev/null) + if [[ ! $FAILED_CNT || $FAILED_CNT == 0 ]] + then + echo "${aCOLOUR[weak]}No services failed" + else + echo "${aCOLOUR[alert]}$FAILED_CNT service(s) failed" + fi + } + + Get_Fail2Ban_Status() + { + Get_Fail2Ban_Status_Raw() + { + if command -v fail2ban-client > /dev/null + then + if [[ $EUID == 0 ]] + then + fail2ban-client banned 2> /dev/null | mawk -F\' -v ip="^$IP4_re$" '{for(i=2;i /dev/null | mawk -F\' -v ip="^$IP4_re$" '{for(i=2;i 20 )) + then + # high number of bans + state="${aCOLOUR[alert]}$count_banned_ips IP(s) banned" + else + # low number of bans + state="${aCOLOUR[weak]}$count_banned_ips IP(s) banned" + fi + ;; + 1) state="${aCOLOUR[alert]}fail2ban-client not available";; + 2) state="${aCOLOUR[highlight]}NOPASSWD sudo required to obtain Fail2Ban status";; + *) state="${aCOLOUR[alert]}Unknown state, code: $code count: $count_banned_ips";; + esac + + echo "$state" + } + + # Convert bytes to GiB (IEC) with one decimal — returns numeric string like "62.8" + Bytes_To_GiB() + { + local bytes=$1 + if [[ $bytes =~ ^[0-9]+$ ]] + then + # Use mawk for consistent floating-point formatting + mawk -v v="$bytes" 'BEGIN{printf "%.1f", v/(1024*1024*1024)}' + else + echo -e "???" + fi + } + + Print_Network_Usage() + { + + # Get WAN IP for namespace $1 (default if empty) + Get_WAN_IP() + { + local ns_cmd_prefix=() + if [[ $1 ]] + then + # add sudo if not root + (( $EUID )) && ns_cmd_prefix+=(sudo -n) + # add netns prefix + ns_cmd_prefix+=(ip netns exec "$1") + fi + "${ns_cmd_prefix[@]}" curl -sSfLm 1 'https://dietpi.com/geoip' + } + + # Print usage stats for namespace $1 (default if empty) + Print_Data_Usage() + { + local netns=$1 + + # Use an array prefix so we can run commands either in a netns + # (ip netns exec ...) or the default namespace, and with sudo if needed + local ns_cmd_prefix=() + if [[ $netns ]] + then + # add sudo if not root + (( $EUID )) && ns_cmd_prefix+=(sudo -n) + # add netns prefix + ns_cmd_prefix+=(ip netns exec "$netns") + fi + + # Query interface list once inside the (optional) namespace and match names + mapfile -t observed_ifaces < <("${ns_cmd_prefix[@]}" ip -br link 2> /dev/null | mawk '{print $1}') + + for IFACE in "${observed_ifaces[@]}" + do + # strip kernel-style peer suffix (e.g. eth0@if8) and use base name + local ifname="${IFACE%%@*}" + # skip loopback + [[ $ifname == 'lo' ]] && continue + + case $ifname in + # wired / predictable names + eth*|en*|lan*) :;; + # wireless + wlan*|wl*) :;; + # vpn/tunnel/virtual + tun*|wg*|vpn*) :;; + *) continue;; + esac + + local RX='N/A' TX='N/A' rx tx ip_addr=' - No IP - ' ip_addr_cidr + local rx_path="/sys/class/net/$ifname/statistics/rx_bytes" + local tx_path="/sys/class/net/$ifname/statistics/tx_bytes" + + if [[ $netns ]]; + then + # If a netns is specified, use the prefix to read the RX/TX statistics files. + rx=$("${ns_cmd_prefix[@]}" cat "$rx_path" 2> /dev/null) + tx=$("${ns_cmd_prefix[@]}" cat "$tx_path" 2> /dev/null) + else + # Else, read the RX/TX statistics files directly. + read -r rx < "$rx_path" + read -r tx < "$tx_path" + fi + + # Process the RX/TX values to convert to GiB if they are numeric, otherwise leave as 'N/A' + [[ $rx =~ ^[0-9]+$ ]] && RX="$(Bytes_To_GiB "$rx") GiB" + [[ $tx =~ ^[0-9]+$ ]] && TX="$(Bytes_To_GiB "$tx") GiB" + + # Get first IPv4 address for this interface in this namespace. If none, leave "- No IP -". + # Scan all fields for a matching IPv4/CIDR token. + ip_addr_cidr=$("${ns_cmd_prefix[@]}" ip -br -4 addr show dev "$ifname" scope global 2> /dev/null | mawk -v re="^$IP4_re/[0-9][0-9]*$" '{for(i=1;i<=NF;i++) if($i ~ re){print $i; exit}}') + [[ $ip_addr_cidr ]] && ip_addr=${ip_addr_cidr%%/*} + + [[ $RX == 'N/A' && $TX == 'N/A' ]] && continue + printf '%b %b%-23s %b %bTX=%11s RX=%11s\n' "$CLI_BULLET" "${aCOLOUR[weak]}" "$ip_addr ($ifname)" "$CLI_SEPARATOR" "${aCOLOUR[weak]}" "$TX" "$RX" + done + } + + mapfile -t namespaces < <(ip netns ls | mawk 'NF>1 {print $1}') + # Prepend an empty entry so the default (root) namespace can be handled in the loop + namespaces=('' "${namespaces[@]}") + + for ns in "${namespaces[@]}" do - # shellcheck disable=SC2292 - [ -f "$i" ] || continue - openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' - return 0 + raw_ns=${ns:-default} + display_ns=$(G_TRUNCATE_MID "$raw_ns" 24) + + IP=$(Get_WAN_IP "$ns" 2>&1) + + if [[ $IP =~ ^$IP4_re ]] + then + # Valid IP detected, keep as-is and print with namespace + echo -e "${aCOLOUR[alt]} $IP [$display_ns]" + + elif [[ ! $IP ]] + then + # If the IP is empty, it likely means the namespace is disconnected or has no Internet access. Print status message. + echo -e "${aCOLOUR[alert]} Disconnected $CLI_SEPARATOR ${aCOLOUR[strong]}$display_ns" + + else + # Print curl errors as is + echo -e "${aCOLOUR[highlight]} $IP [$display_ns]" + fi + + Print_Data_Usage "$ns" + done + } + + Menu_Disk_Mounts() + { + # ignore check for single/double quote expansion issue; single/double quotes for glob; + # shellcheck disable=SC2016,SC2086 + pattern_hint="The matching mount 'target's returned by the 'findmnt' command will be reported. +To use regex, start the pattern with 're:', otherwise shell globbing is used. +For example: '/mnt/*' or 're:^/mnt/.*/*$'" + + # Build mount list from findmnt (preserve findmnt order) + local -a available_mounts + mapfile -t available_mounts < <(findmnt -Dnro TARGET --real 2>/dev/null) + # Build quick lookup maps used to determine which mounts are present + # and which are configured by the user: + # - `avail_map`: mounts currently detected on the system + # - `cfg_map` : mounts the user has previously configured to track + declare -A avail_map=() cfg_map=() + for m in "${available_mounts[@]}"; do avail_map["$m"]=1; done + for p in "${aDISK_SPACE_MOUNTS[@]}"; do cfg_map["$p"]=1; done + + # Prepare the checklist array and a mapping from generated tags back to + # mount paths. Entries are added in this order: + # 1) configured-but-missing mounts (so the user notices them), + # 2) a 'Custom matching' toggle, and + # 3) currently available mounts. + G_WHIP_CHECKLIST_ARRAY=() + declare -A tag2mount=() + local idx=0 tag desc default + + # Add configured-but-missing mounts at the top. + # Tags are created to be safe for G_WHIP_CHECKLIST (no spaces, no special characters). + # The tag is used to map back to the mount path. + for p in "${aDISK_SPACE_MOUNTS[@]}"; do + [[ -n "$p" && -z ${avail_map[$p]} ]] || continue + tag="m$idx" + desc="[not found] $p" + default='on' + G_WHIP_CHECKLIST_ARRAY+=("$tag" "$desc" "$default") + tag2mount["$tag"]="$p" + ((idx++)) + done + + # Add Custom matching option to allow adding user patterns. + local custom_tag='CUSTOM_MATCHING' + if (( ${aENABLED[disk_patterns]} )); then default='on'; else default='off'; fi + G_WHIP_CHECKLIST_ARRAY+=("$custom_tag" "Custom matching (globs/regex patterns)" "$default") + + # Add available mounts from `findmnt` to the list. + for m in "${available_mounts[@]}"; do + tag="m$idx" + desc="$m" + if [[ ${cfg_map[$m]} ]]; then default='on'; else default='off'; fi + G_WHIP_CHECKLIST_ARRAY+=("$tag" "$desc" "$default") + tag2mount["$tag"]="$m" + ((idx++)) + done + + # Display checklist (enumerated) + G_WHIP_CHECKLIST_ENUM=1 + G_WHIP_CHECKLIST "Select mounts to track. Use 'Custom matching' to manage globs/regex patterns." + + local sel + aENABLED[disk_patterns]=0 # Manage_Disk_Patterns sets to 1 if applicable + aDISK_SPACE_MOUNTS=() # Reset the mount list, it will be re-filled based on user selection below + + # Process selections: G_WHIP_RETURNED_VALUE contains selected tags. + # Rebuild mounts array and call Manage_Disk_Patterns if the user selected the custom matching option. + for sel in $G_WHIP_RETURNED_VALUE; do + if [[ $sel == "$custom_tag" ]]; then Menu_Disk_Patterns; continue; fi + if [[ ${tag2mount[$sel]} ]]; then aDISK_SPACE_MOUNTS+=("${tag2mount[$sel]}"); fi + done + } + + # Manage disk space pattern list (globs / regex) + Menu_Disk_Patterns() + { + local pattern_hint sel idx + + # Multi-line help text for the patterns submenu + pattern_hint="To use regex, start the pattern with 're:', otherwise shell globbing is used. +For example: '/mnt/*' or 're:^/mnt/.*/*$'" + + while : + do + # Build menu: Add first, then each active pattern, then Done + G_WHIP_MENU_ARRAY=() + G_WHIP_MENU_ARRAY+=( 'Add' 'Add a new pattern' ) + for idx in "${!aDISK_SPACE_PATTERNS[@]}"; do + G_WHIP_MENU_ARRAY+=("$idx" "${aDISK_SPACE_PATTERNS[$idx]}") + done + G_WHIP_MENU_ARRAY+=( 'Done' 'Finish pattern configuration' ) + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU "Manage disk space match patterns. The matching mounts returned by 'findmnt' will be reported.\n$pattern_hint" || break + + sel=$G_WHIP_RETURNED_VALUE + case "$sel" in + Add) + G_WHIP_DEFAULT_ITEM='' + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a pattern + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$pattern_hint\n\nEdit pattern (empty removes it):" || continue + aDISK_SPACE_PATTERNS+=("$G_WHIP_RETURNED_VALUE") + ;; + Done) + break + ;; + *) + # Selected an existing pattern index -> edit in-place + if [[ $sel =~ ^[0-9]+$ && -n "${aDISK_SPACE_PATTERNS[$sel]:-}" ]] + then + G_WHIP_DEFAULT_ITEM=${aDISK_SPACE_PATTERNS[$sel]} + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a pattern + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$pattern_hint\n\nEdit pattern (empty removes it):" + aDISK_SPACE_PATTERNS[$sel]="$G_WHIP_RETURNED_VALUE" + else + # Unknown selection, ignore + continue + fi + ;; + esac + + for idx in "${!aDISK_SPACE_PATTERNS[@]}"; do + # Remove any empty entries (cleared by user) + [[ -n "${aDISK_SPACE_PATTERNS[$idx]:-}" ]] || unset 'aDISK_SPACE_PATTERNS[$idx]' + done + done + + # Re-index the array to remove gaps caused by unset + aDISK_SPACE_PATTERNS=("${aDISK_SPACE_PATTERNS[@]}") + + # If any patterns are configured, enable the disk_patterns option + (( ${#aDISK_SPACE_PATTERNS[@]} > 0 )) && aENABLED[disk_patterns]=1 + } + + Print_Disk_Usage() + { + local results=() + local -A seen=() + + if (( ( ${#aDISK_SPACE_PATTERNS[@]} == 0 ) || ( ${aENABLED[disk_patterns]} == 0 ) )) && (( ${#aDISK_SPACE_MOUNTS[@]} == 0 )) + then + echo -e "${aCOLOUR[highlight]}No disk patterns provided, nothing to display." + return + fi + + local -a mount_order=() mount_lines + declare -A mount_used mount_size mount_perc mount_name + local line mnt_target used size perc name + + # Pull all mounts once (-D -n -r) in bytes (-b). Rebuild TARGET safely + # (handles spaces) and compute USE% with one decimal using mawk from USED/SIZE. + # Use ASCII Unit Separator (0x1F) as a safe field delimiter to avoid collisions with mount names + mapfile -t mount_lines < <(findmnt -b -Dnro TARGET,USED,SIZE --real 2>/dev/null | mawk -v SEP="$(printf '\037')" '{used=$(NF-1); size=$NF; target=$1; for(i=2;i<=NF-2;i++) target=target" "$i; perc=(size>0? used/size*100 : 0); printf "%s%s%s%s%s%s%s\n", target, SEP, used, SEP, size, SEP, sprintf("%.1f%%", perc)}') + + # Keep mount_order[] to preserve findmnt's (consistent) order and aDISK_SPACE_PATTERNS' order. + for line in "${mount_lines[@]}" + do + # Each line fields are separated by ASCII Unit Separator (0x1F): TARGETUSEDSIZEUSE% + IFS=$'\x1f' read -r mnt_target used size perc <<< "$line" + mount_used["$mnt_target"]="$used" + mount_size["$mnt_target"]="$size" + mount_perc["$mnt_target"]="$perc" + mount_name["$mnt_target"]="$(basename "$mnt_target")" + mount_order+=("$mnt_target") + done + + # Iterate mounts in the original findmnt order and match against patterns. + for mnt_target in "${mount_order[@]}" + do + local matched=0 + + # Test against explicit exact-match patterns (highest precedence) + for p in "${aDISK_SPACE_MOUNTS[@]}" + do + [[ "$mnt_target" == "$p" ]] && { matched=1; break; } + done + + # If not matched yet, test globs and regex patterns from match_patterns if enabled + if (( matched == 0 )) && (( aENABLED[disk_patterns] == 1 )) + then + for pattern in "${aDISK_SPACE_PATTERNS[@]}" + do + if [[ $pattern == 're:'* ]] + then + re=${pattern#re:} + if [[ $mnt_target =~ $re ]] + then + matched=1; break + fi + else + # shellcheck disable=SC2254 + case "$mnt_target" in + $pattern) matched=1; break;; + *) :;; + esac + fi + done + fi + + if (( matched )) + then + used="${mount_used[$mnt_target]}" + size="${mount_size[$mnt_target]}" + perc="${mount_perc[$mnt_target]}" + name="${mount_name[$mnt_target]}" + + # Convert raw bytes to GiB (IEC) numeric strings for display + used_fmt="$(Bytes_To_GiB "$used")" + size_fmt="$(Bytes_To_GiB "$size")" + + # Mark seen and collect formatted result (fields separated by ASCII Unit Separator) + seen[$mnt_target]=1 + results+=("$mnt_target"$'\x1f'"$used_fmt"$'\x1f'"$size_fmt"$'\x1f'"$perc"$'\x1f'"$name") + fi + done + + if (( ${#results[@]} == 0 )) + then + echo -e "${aCOLOUR[highlight]}No mounts were matched, check your configuration." + return + fi + + # For explicit mounts configured but not present in findmnt, add placeholder entries + # at the bottom of the list. + for p in "${aDISK_SPACE_MOUNTS[@]}" + do + [[ -n "$p" && -z ${seen[$p]} ]] || continue + used_fmt="???" + size_fmt="???" + perc="???" + name="$(basename "$p")" + results+=("$p"$'\x1f'"$used_fmt"$'\x1f'"$size_fmt"$'\x1f'"$perc"$'\x1f'"$name") + done + + # Disambiguate duplicate basenames: if multiple mounts share the same + # basename (e.g. /tmp and /mnt/tmp), show the full path for those entries + # so the user can tell them apart. Compute display widths accordingly. + declare -A name_count=() + for entry in "${results[@]}" + do + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + (( name_count["$name"]++ )) + done + + local length=0 + for i in "${!results[@]}" + do + entry=${results[$i]} + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + if (( name_count["$name"] > 1 )) + then + dname="$(G_TRUNCATE_MID "$mnt_target" 30)" + else + dname="$(G_TRUNCATE_MID "$name" 30)" + fi + (( ${#dname} > length )) && length=${#dname} + results[$i]="$mnt_target"$'\x1f'"$used"$'\x1f'"$size"$'\x1f'"$perc"$'\x1f'"$dname" + done + + # Print results + for entry in "${results[@]}" + do + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + # Print aligned columns: {name} : {percent used} : {used} of {size} + printf "%b %b%-${length}s %b %b%6b %b %b%7s GiB of %7s GiB\n" "$CLI_BULLET" "${aCOLOUR[alt]}" "$name" "$CLI_SEPARATOR" "${aCOLOUR[strong]}" "$perc" "$CLI_SEPARATOR" "${aCOLOUR[weak]}" "$used" "$size" + done + } + + Get_VPN_Status() + { + local state=$(/boot/dietpi/dietpi-vpn status 2>&1) + case $state in + Connected*) state="${aCOLOUR[weak]}$state${COLOUR_RESET}";; + Disconnected*) state="${aCOLOUR[alert]}$state${COLOUR_RESET}";; + *) state="${aCOLOUR[highlight]}$state${COLOUR_RESET}";; + esac + echo "$state" + } + + Menu_Custom_Commands() + { + local hint sel key cmd + + # Help text + # shellcheck disable=SC2016 + hint='Example:\necho -e "${aCOLOUR[good]}Hello World! :)"' + name_hint="Enter a name for the command. It must be unique, safe, and not empty." + + while : + do + # Build menu: `Add` first, then each active command, then `Done` + G_WHIP_MENU_ARRAY=() + G_WHIP_MENU_ARRAY+=( 'Add' 'Add a new pattern' ) + for key in "${aCUSTOM_COMMANDS_ORDER[@]}"; do + G_WHIP_MENU_ARRAY+=("$key" "${aCUSTOM_COMMANDS[$key]}") + done + G_WHIP_MENU_ARRAY+=( 'Done' 'Finish commands configuration' ) + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU "Manage custom commands. Each command gets a name and a corresponding shell command.\n$hint" || break + + sel=$G_WHIP_RETURNED_VALUE + case "$sel" in + Add) + # Add a new command + G_WHIP_DEFAULT_ITEM='' + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a command + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + + G_WHIP_INPUTBOX "$hint\n\nEdit command (empty removes it):" || continue + cmd="$G_WHIP_RETURNED_VALUE" + + if [[ -z "$cmd" ]] + then + # Empty command, do not add to array + continue + else + G_WHIP_DEFAULT_ITEM='' + G_WHIP_INPUTBOX_REGEX='.+' # Non-empty + G_WHIP_INPUTBOX_REGEX_TEXT='must be unique, safe, and must not be empty' + + while :; + do + # New command entered, prompt for a name + G_WHIP_INPUTBOX "$name_hint" || continue + key="$G_WHIP_RETURNED_VALUE" + if [[ -n "${aCUSTOM_COMMANDS[$key]:-}" ]] + then + # Key already exists, prompt again + continue + else + # Key is new and valid, accept it + break + fi + done + aCUSTOM_COMMANDS+=([$key]="$cmd") + aCUSTOM_COMMANDS_ORDER+=("$key") + fi + ;; # End adding of command + Done) + break + ;; + *) + # Edit a command + if [[ -n "$sel" ]] + then + G_WHIP_DEFAULT_ITEM="${aCUSTOM_COMMANDS[$sel]}" + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a command + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$hint\n\nEdit command (empty removes it):" + cmd="$G_WHIP_RETURNED_VALUE" + + if [[ -z "$cmd" ]] + then + # Empty command, remove from array + unset "aCUSTOM_COMMANDS[$sel]" + # Re-index array + for i in "${!aCUSTOM_COMMANDS_ORDER[@]}"; do + [[ "${aCUSTOM_COMMANDS_ORDER[$i]}" == "$sel" ]] && unset 'aCUSTOM_COMMANDS_ORDER[$i]' + done + aCUSTOM_COMMANDS_ORDER=("${aCUSTOM_COMMANDS_ORDER[@]}") + continue + else + # New command entered, prompt for a name + G_WHIP_DEFAULT_ITEM="$sel" + G_WHIP_INPUTBOX_REGEX='.+' # Non-empty + G_WHIP_INPUTBOX_REGEX_TEXT='must be unique, safe, and must not be empty' + + while :; + do + G_WHIP_INPUTBOX "$name_hint" || continue + key="$G_WHIP_RETURNED_VALUE" + if [[ $key == "$sel" ]] + then + # Key unchanged, just update the command + aCUSTOM_COMMANDS[$key]="$cmd" + break + elif [[ -n "${aCUSTOM_COMMANDS[$key]:-}" && "$key" != "$sel" ]] + then + # Key changed to an existing key, prompt again + continue + else + # Key is new and valid, accept it + # Re-index array + for i in "${!aCUSTOM_COMMANDS_ORDER[@]}"; do + [[ "${aCUSTOM_COMMANDS_ORDER[$i]}" == "$sel" ]] && unset 'aCUSTOM_COMMANDS_ORDER[$i]' + done + aCUSTOM_COMMANDS_ORDER=("${aCUSTOM_COMMANDS_ORDER[@]}") + unset "aCUSTOM_COMMANDS[$sel]" + + # Add the new key and command + aCUSTOM_COMMANDS+=([$key]="$cmd") + aCUSTOM_COMMANDS_ORDER+=("$key") + break + fi + done + fi + else + # Unknown selection, ignore + continue + fi + ;; # End editing of command + esac # End case "$sel" + done # End command menu loop + + # If any commands are configured, enable the custom_commands option + (( ${#aCUSTOM_COMMANDS_ORDER[@]} > 0 )) && aENABLED[custom_commands]=1 + } + + Print_Custom_Commands() + { + local key output status + for key in "${aCUSTOM_COMMANDS_ORDER[@]}" + do + [[ -n "${aCUSTOM_COMMANDS[$key]:-}" ]] || continue + # Capture output and exit code + output="$(eval "${aCUSTOM_COMMANDS[$key]}" 2>&1)" + status=$? + if (( status )) + then + Print_Item_State "$key" "${aCOLOUR[alert]}Command failed (code $status): $output${COLOUR_RESET}" + else + Print_Item_State "$key" "$output" + fi done - echo 'No certificate found' } Print_Credits() { - echo -e " ${aCOLOUR[2]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" + echo -e " ${aCOLOUR[weak]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" [[ -f '/boot/dietpi/.prep_info' ]] && mawk 'NR==1 {sub(/^0$/,"DietPi Core Team");a=$0} NR==2 {print " Image by : "a" (pre-image: "$0")"}' /boot/dietpi/.prep_info @@ -241,32 +1069,40 @@ $GREEN_LINE" # DietPi update available? if [[ $AVAILABLE_UPDATE ]] then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" # New DietPi live patches available? elif (( $LIVE_PATCHES )) then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" # APT updates available? elif (( $PACKAGE_COUNT )) then - echo -e " ${aCOLOUR[1]}apt upgrade$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}apt upgrade$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" # Reboot required to finalise kernel upgrade? elif (( $REBOOT_REQUIRED )) then - echo -e " ${aCOLOUR[1]}reboot$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}reboot$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[alert]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" fi } Print_Useful_Commands() { - echo -e " ${aCOLOUR[1]}dietpi-launcher$COLOUR_RESET $GREEN_SEPARATOR All the DietPi programs in one place - ${aCOLOUR[1]}dietpi-config$COLOUR_RESET $GREEN_SEPARATOR Feature rich configuration tool for your device - ${aCOLOUR[1]}dietpi-software$COLOUR_RESET $GREEN_SEPARATOR Select optimised software for installation - ${aCOLOUR[1]}htop$COLOUR_RESET $GREEN_SEPARATOR Resource monitor - ${aCOLOUR[1]}cpu$COLOUR_RESET $GREEN_SEPARATOR Shows CPU information and stats\n" + echo -e " ${aCOLOUR[strong]}dietpi-launcher$COLOUR_RESET $CLI_SEPARATOR All the DietPi programs in one place + ${aCOLOUR[strong]}dietpi-config$COLOUR_RESET $CLI_SEPARATOR Feature rich configuration tool for your device + ${aCOLOUR[strong]}dietpi-software$COLOUR_RESET $CLI_SEPARATOR Select optimised software for installation + ${aCOLOUR[strong]}htop$COLOUR_RESET $CLI_SEPARATOR Resource monitor + ${aCOLOUR[strong]}cpu$COLOUR_RESET $CLI_SEPARATOR Shows CPU information and stats\n" + } + + Print_Item_State() + { + # Print a single line item with green bullet, description "$1", green separator and state "$2" + # state should contain color and format codes as needed + local subtitle="$1" state="$2" + echo -e "$CLI_BULLET${aCOLOUR[strong]} $subtitle $CLI_SEPARATOR $state$COLOUR_RESET" } Print_Banner_raw() @@ -275,63 +1111,68 @@ $GREEN_LINE" # Large Format Hostname # shellcheck disable=SC1091 - (( ${aENABLED[14]} )) && . /boot/dietpi/func/dietpi-print_large "$(&1)" + (( ${aENABLED[uptime]} )) && Print_Item_State "${aDESCRIPTION[uptime]}" "$(uptime -p 2>&1)" # Linux kernel version - (( ${aENABLED[20]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[20]} $GREEN_SEPARATOR $(uname -r 2>&1)" + (( ${aENABLED[kernel]} )) && Print_Item_State "${aDESCRIPTION[kernel]}" "$(uname -r 2>&1)" # CPU temp - (( ${aENABLED[2]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[2]} $GREEN_SEPARATOR $(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" + (( ${aENABLED[cpu_temp]} )) && Print_Item_State "${aDESCRIPTION[cpu_temp]}" "$(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" # RAM usage - (( ${aENABLED[17]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[17]} $GREEN_SEPARATOR $(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" + (( ${aENABLED[ram_usage]} )) && Print_Item_State "${aDESCRIPTION[ram_usage]}" "$(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" # Load average - (( ${aENABLED[18]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[18]} $GREEN_SEPARATOR $(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" + (( ${aENABLED[load_average]} )) && Print_Item_State "${aDESCRIPTION[load_average]}" "$(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" # Hostname - (( ${aENABLED[3]} && ! ${aENABLED[14]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[3]} $GREEN_SEPARATOR $(&1)" + (( ${aENABLED[nis_domainname]} )) && Print_Item_State "${aDESCRIPTION[nis_domainname]}" "$(hostname -y 2>&1)" # LAN IP - Print_Local_Ip + (( ${aENABLED[lan_ip]} )) && Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)" # WAN IP + location info - (( ${aENABLED[6]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[6]} $GREEN_SEPARATOR $(G_GET_WAN_IP 2>&1)" + (( ${aENABLED[wan_ip]} )) && Print_Item_State "${aDESCRIPTION[wan_ip]}" "$(G_GET_WAN_IP 2>&1)" # DietPi-VPN connection status - (( ${aENABLED[13]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[13]} $GREEN_SEPARATOR $(/boot/dietpi/dietpi-vpn status 2>&1)" - # Disk usage (RootFS) - (( ${aENABLED[7]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[7]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent / | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" - # Disk usage (DietPi userdata) - (( ${aENABLED[8]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[8]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent /mnt/dietpi_userdata | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" + (( ${aENABLED[vpn_status]} )) && Print_Item_State "${aDESCRIPTION[vpn_status]}" "$(Get_VPN_Status 2>&1)" # Weather - (( ${aENABLED[9]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[9]} $GREEN_SEPARATOR $(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" + (( ${aENABLED[weather]} )) && Print_Item_State "${aDESCRIPTION[weather]}" "$(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" # Let's Encrypt cert status - if (( ${aENABLED[16]} )) - then - echo -en "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[16]} $GREEN_SEPARATOR " - if [[ $EUID == 0 ]] - then - # Running as root - Print_Cert_Status 2>&1 - else - # Running as non-root: Fail silently without NOPASSWD to avoid password prompt, but print info instead - sudo -n dash -c "$(declare -f Print_Cert_Status); Print_Cert_Status 2>&1" 2> /dev/null || echo 'NOPASSWD sudo required to obtain cert status' - fi - fi - # Custom - (( ${aENABLED[10]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[10]} $GREEN_SEPARATOR $(bash "$FP_CUSTOM" 2>&1)" + (( ${aENABLED[letsencrypt]} )) && Print_Item_State "Let's Encrypt Cert" "$(Get_Cert_Status 2>&1)" + # Systemd status + (( ${aENABLED[systemd]} )) && Print_Item_State "${aDESCRIPTION[systemd]}" "$(Get_Systemd_Status 2>&1)" + # Fail2Ban status + (( ${aENABLED[fail2ban]} )) && Print_Item_State "${aDESCRIPTION[fail2ban]}" "$(Get_Fail2Ban_Status 2>&1)" # MOTD - if (( ${aENABLED[12]} )) + if (( ${aENABLED[motd]} )) then local motd fp_motd='/run/dietpi/.dietpi_motd' [[ -f $fp_motd ]] || curl -sSfLm 3 'https://dietpi.com/motd' -o "$fp_motd" # shellcheck disable=SC1090 - [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[12]} $GREEN_SEPARATOR $motd" + [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && Print_Item_State "${aDESCRIPTION[motd]}" "$motd" fi - echo -e "$GREEN_LINE\n" - (( ${aENABLED[15]} )) && Print_Credits + # Network usage by namespace + (( ${aENABLED[network_usage]} )) && Print_Subheader 'Network Traffic' && Print_Network_Usage 2>&1 + # Disk usage + (( ${aENABLED[disk_usage]} )) && Print_Subheader 'Disk Usage' && Print_Disk_Usage 2>&1 + # Custom commands + (( ${aENABLED[custom_commands]} )) && Print_Subheader 'Custom Commands' && Print_Custom_Commands + + # Show separator if any relevant follow-up sections or updates are present. + # Most flags are numeric (0/1); AVAILABLE_UPDATE is a version string when present. + if [[ -n "$AVAILABLE_UPDATE" ]] || (( aENABLED[dietpi_commands] || aENABLED[motd] || aENABLED[credits] || LIVE_PATCHES || PACKAGE_COUNT || REBOOT_REQUIRED )); then + echo -e "$CLI_LINE" + fi + + (( ${aENABLED[credits]} )) && Print_Credits + (( ${aENABLED[dietpi_commands]} )) && Print_Useful_Commands Print_Updates - (( ${aENABLED[11]} )) && Print_Useful_Commands + + # Notify user if prefs or colors were migrated from legacy numeric indices during this run + (( $DIETPI_BANNER_MIGRATED_DURING_RUN )) && echo -e " ${aCOLOUR[highlight]}Your preferences need to be migrated, run dietpi-banner and validate your settings.$COLOUR_RESET" + (( DIETPI_BANNER_MIGRATED_CUSTOM_COMMAND )) && echo -e " ${aCOLOUR[highlight]}Your custom command from '.dietpi-banner_custom' needs to be migrated. + Custom commands are now created through 'dietpi-banner'. You can run + a script as a command by configuring 'bash \"/path/to/script\"' .$COLOUR_RESET" } Print_Banner() @@ -339,68 +1180,84 @@ $GREEN_LINE" G_TERM_CLEAR # Pipe to awk script for word-wrap if enabled - if (( ${aENABLED[19]} )) + if (( ${aENABLED[word_wrap]} )) then Print_Banner_raw | mawk -v "MAXCOL=$(tput cols)" -v "INDENT_TYPE=$BW_INDENT_TYPE" -v "INDENT_FIXED=$BW_INDENT_FIXED" -f "$FP_BANNERWRAP_AWK" else Print_Banner_raw fi + echo -e "$COLOUR_RESET" } Menu_Main() { G_WHIP_CHECKLIST_ARRAY=() - for i in "${!aDESCRIPTION[@]}" + for k in "${MENU_ITEMS[@]}" do local state='off' - (( ${aENABLED[$i]:=0} == 1 )) && state='on' - G_WHIP_CHECKLIST_ARRAY+=("$i" "${aDESCRIPTION[$i]}" "$state") + (( ${aENABLED[$k]:=0} == 1 )) && state='on' + G_WHIP_CHECKLIST_ARRAY+=("$k" "${aDESCRIPTION[$k]}" "$state") done + G_WHIP_CHECKLIST_ENUM=1 # set the menu to use integer indices for the display G_WHIP_CHECKLIST "Please (de)select options via spacebar to be shown in the $G_PROGRAM_NAME:" || return 0 - for i in "${!aDESCRIPTION[@]}" + for k in "${MENU_ITEMS[@]}" do - aENABLED[$i]=0 + aENABLED[$k]=0 done - for i in $G_WHIP_RETURNED_VALUE + for selection in $G_WHIP_RETURNED_VALUE do - aENABLED[$i]=1 - - if (( $i == 10 )) - then - # Custom entry - [[ -f $FP_CUSTOM ]] && read -r G_WHIP_DEFAULT_ITEM < "$FP_CUSTOM" || G_WHIP_DEFAULT_ITEM='echo '\''Hello World!'\' - G_WHIP_INPUTBOX 'You have chosen to show a custom entry in the banner. -Please enter the desired command here.\n -NB: It is executed as bash script, so it needs to be in bash compatible syntax. - For multi-line or non-bash scripts, keep it separate and only add the script call here.' || continue + # Enable options (the typical action for the checklist) + aENABLED[$selection]=1 - echo "$G_WHIP_RETURNED_VALUE" > "$FP_CUSTOM" - - G_WHIP_DEFAULT_ITEM=${aDESCRIPTION[10]} - G_WHIP_INPUTBOX 'Please enter a meaningful name to be shown in front of your custom command output:' && aDESCRIPTION[10]=$G_WHIP_RETURNED_VALUE - - elif (( $i == 19 )) - then - # Banner word-wrap options - G_WHIP_MENU_ARRAY=( - 'colon' 'Indent to the green colon if present, else to the green dash' - 'dash' 'Indent to the green dash character' - 'fixed' 'Indent to a fixed offset' - ) - G_WHIP_DEFAULT_ITEM=$BW_INDENT_TYPE - G_WHIP_MENU 'Choose how word-wrapped lines shall be indented:' || continue - BW_INDENT_TYPE=$G_WHIP_RETURNED_VALUE - [[ $BW_INDENT_TYPE == 'fixed' ]] || continue - G_WHIP_DEFAULT_ITEM=$BW_INDENT_FIXED - G_WHIP_INPUTBOX_REGEX='^[1-9][0-9]*$' G_WHIP_INPUTBOX_REGEX_TEXT='be a number' - G_WHIP_INPUTBOX 'Please set the fixed offset column to indent word-wrapped lines to:' && BW_INDENT_FIXED=$G_WHIP_RETURNED_VALUE - fi + # Special actions for some items (like submenus) that require additional configuration + case $selection in + 'custom_commands') + Menu_Custom_Commands + # If no commands are configured, disable the custom commands option + (( ${#aCUSTOM_COMMANDS[@]} )) || aENABLED[custom_commands]=0 + ;; + 'word_wrap') + # Banner word-wrap options + G_WHIP_MENU_ARRAY=( + 'colon' 'Indent to the green colon if present, else to the green dash' + 'dash' 'Indent to the green dash character' + 'fixed' 'Indent to a fixed offset' + ) + G_WHIP_DEFAULT_ITEM=$BW_INDENT_TYPE + G_WHIP_MENU 'Choose how word-wrapped lines shall be indented:' || continue + BW_INDENT_TYPE=$G_WHIP_RETURNED_VALUE + [[ $BW_INDENT_TYPE == 'fixed' ]] || continue + G_WHIP_DEFAULT_ITEM=$BW_INDENT_FIXED + G_WHIP_INPUTBOX_REGEX='^[1-9][0-9]*$' G_WHIP_INPUTBOX_REGEX_TEXT='be a number' + G_WHIP_INPUTBOX 'Please set the fixed offset column to indent word-wrapped lines to:' && BW_INDENT_FIXED=$G_WHIP_RETURNED_VALUE + ;; + 'disk_usage') + Menu_Disk_Mounts + # If no disks are configured, disable the disk usage option + if (( ( ${#aDISK_SPACE_PATTERNS[@]} == 0 ) || ( aENABLED[disk_patterns] == 0 ) )) + then + (( ${#aDISK_SPACE_MOUNTS[@]} == 0 )) && aENABLED[disk_usage]=0 + fi + ;; + dietpi_commands|credits|motd|network_usage|letsencrypt|systemd|fail2ban|large_hostname|device_model|uptime|kernel|cpu_temp|ram_usage|load_average|hostname|nis_domainname|lan_ip|wan_ip|vpn_status|weather) + # No additional configuration needed for these options + ;; + *) + G_DIETPI-NOTIFY 1 "Unknown menu selection: $selection" >&2 + ;; + esac done - [[ -f $FP_CUSTOM ]] || aENABLED[10]=0 + # Before saving, create a backup of the existing prefs if migrating. + # FP_SAVEFILE gets truncated when calling Save() below, so we need to backup first. + if [[ -f $FP_SAVEFILE && $DIETPI_BANNER_MIGRATED_DURING_RUN == 1 ]] + then + local backup="$FP_SAVEFILE.bak-$(date -Iseconds)" + cp -a "$FP_SAVEFILE" "$backup" + fi Save > "$FP_SAVEFILE" } @@ -409,7 +1266,7 @@ NB: It is executed as bash script, so it needs to be in bash compatible syntax. # Main Loop #///////////////////////////////////////////////////////////////////////////////////// case $INPUT in - 0) Print_Header; Print_Local_Ip;; + 0) Print_Header; Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)";; 1) Print_Banner;; '') Menu_Main; Print_Banner;; *) G_DIETPI-NOTIFY 1 "Invalid input \"$*\"\n\nUsage:$USAGE"; exit 1;; diff --git a/dietpi/func/dietpi-cli b/dietpi/func/dietpi-cli new file mode 100755 index 0000000000..87d66159d0 --- /dev/null +++ b/dietpi/func/dietpi-cli @@ -0,0 +1,295 @@ +#!/bin/bash + +{ + #//////////////////////////////////// + # DietPi CLI Configuration Script + # + #//////////////////////////////////// + # Created by Tim Olson / timjolson@users.noreply.github.com + # + # Info: + # - Location: /boot/dietpi/func/dietpi-cli + # - Purpose: Provide a small interactive tool to preview and tune the + # primary CLI colour slots used by `dietpi-banner` (accent, strong, weak, + # alert, good, highlight, alt). This script loads current preferences from + # /boot/dietpi/.dietpi-banner if present. Saving changes is optional, and + # defaults can be restored at any time. + # + USAGE=' +- dietpi-cli = CLI customisation menu +- dietpi-cli 0 = Show default CLI preferences, prompt to save as the active settings +' #//////////////////////////////////// + + # Grab input + INPUT=$* + + # Import DietPi-Globals -------------------------------------------------------------- + . /boot/dietpi/func/dietpi-globals + # - Allow concurrent references (loading) but a single menu call only + if [[ ! $INPUT ]] + then + readonly G_PROGRAM_NAME='DietPi-CLI' + G_CHECK_ROOT_USER "$@" # Some operations may require root to store settings + G_INIT + else + # Apply safe locale in non-menu mode (where G_INIT does it) + export LC_ALL='C.UTF-8' LANG='C.UTF-8' + fi + # Import DietPi-Globals -------------------------------------------------------------- + + # Preferences file used by dietpi-banner. We source this file to reflect + # the current banner configuration when presenting examples to the user. + readonly FP_CLI='/boot/dietpi/.dietpi-banner' + + # Minimal palette used by dietpi-banner + declare -g COLOUR_RESET='\e[0m' + declare -gA aCOLOUR_DEFAULTS=( + [accent]='\e[38;5;154m' # Lines, bullets, separators (DietPi green) + [strong]='\e[1m' # Bold / primary text + [weak]='\e[90m' # Subdued / secondary text + [alert]='\e[91m' # Alert / error + [good]='\e[1;32m' # Success / positive + [highlight]='\e[1;33m' # Warning / highlight + [alt]='\e[1;36m' # Alternate / emphasis + ) + + # Predefined formats and defaults + declare -gA aFORMATS=() + declare -gA aCOLOUR=() + declare -ga FORMAT_ORDER=() + for key in "${!aCOLOUR_DEFAULTS[@]}"; do + aCOLOUR[$key]="${aCOLOUR_DEFAULTS[$key]}" # Make a transient copy of the settings for use in the menu. This is what the user edits. + aFORMATS[$key]="${aCOLOUR_DEFAULTS[$key]}" # Initialize the options array with default values + FORMAT_ORDER+=("$key") # Keep the order of the original keys for display in the menu + done + + # Add additional predefined formats for selection in the menu + aFORMATS+=( + [PLAIN]=$'\e[0m' + [UNDERLINE]=$'\e[4m' + [DIM]=$'\e[2m' + [FG_BLACK]=$'\e[30m' + [FG_RED]=$'\e[31m' + [FG_GREEN]=$'\e[32m' + [FG_YELLOW]=$'\e[33m' + [FG_BLUE]=$'\e[34m' + [FG_MAGENTA]=$'\e[35m' + [FG_CYAN]=$'\e[36m' + [FG_WHITE]=$'\e[37m' + [BRIGHT_RED]=$'\e[91m' + [BRIGHT_GREEN]=$'\e[92m' + [BRIGHT_YELLOW]=$'\e[93m' + [BRIGHT_BLUE]=$'\e[94m' + [GOLD]=$'\e[38;5;220m' + [INVERSE]=$'\e[7m' + [BG_RED]=$'\e[41m' + [BG_GREEN]=$'\e[42m' + [BG_BLUE]=$'\e[44m' + ) + # Append additional predefined formats to the display order + FORMAT_ORDER+=(PLAIN UNDERLINE DIM FG_BLACK FG_RED FG_GREEN FG_YELLOW FG_BLUE FG_MAGENTA FG_CYAN FG_WHITE BRIGHT_RED BRIGHT_GREEN BRIGHT_YELLOW GOLD INVERSE BG_RED BG_GREEN BG_BLUE) + + # Convenience derived strings for examples and menus. Call Make_Patterns() + # after updating any aCOLOUR entries so the UI reflects changes. + Make_Patterns() + { + declare -g CLI_LINE=" ${aCOLOUR[accent]}─────────────────────────────────────────────────────$COLOUR_RESET" + declare -g CLI_BULLET=" ${aCOLOUR[accent]}-$COLOUR_RESET" + declare -g CLI_SEPARATOR="${aCOLOUR[accent]}:$COLOUR_RESET" + } + + # Load defaults suitable for most displays. Intended to be a safe baseline. + Load_CLI_Defaults() + { + for key in "${!aCOLOUR_DEFAULTS[@]}"; do + aCOLOUR[$key]="${aCOLOUR_DEFAULTS[$key]}" + done + + Make_Patterns + } + + # Load current user preferences from $FP_CLI when available. Start with + # defaults so missing keys don't break the UI. + Load_CLI_Preferences() + { + Load_CLI_Defaults + # If the prefs file exists, source only whitelisted assignment lines so + # unexpected content cannot be executed. + if [[ -f $FP_CLI ]] + then + tmpfile=$(mktemp) || { G_DIETPI-NOTIFY 1 "Failed to create temporary file for dietpi-cli; aborting."; exit 1; } + # Keep only aCOLOUR assignments; we only want colour slots from prefs. + grep -E '^aCOLOUR\[' "$FP_CLI" > "$tmpfile" || true + # shellcheck disable=SC1090 + . "$tmpfile" + rm -f "$tmpfile" + fi + Make_Patterns + } + + # Print a sourceable preferences fragment to stdout. Convert actual ESC + # bytes into the two-character sequence \e so the file can be safely + # sourced into other shell scripts. + Save() + { + if [[ ! -f "$FP_CLI" ]]; then + if ! touch "$FP_CLI" 2>/dev/null; then + G_DIETPI-NOTIFY 1 "Failed to create preferences file: $FP_CLI" && return 1 + fi + fi + + # Persist each aCOLOUR entry using the in-place injector. + # Use a pattern that matches either quoted or unquoted keys inside [] + for key in accent strong weak alert good highlight alt; do + val="${aCOLOUR[$key]}" + # Convert ESC byte to the two-character sequence \e. Use a + # pattern that embeds the literal ESC to avoid $'..' expansion + # edge-cases and ensure the resulting string contains a + # backslash followed by 'e'. + esc=$(printf '%s' "$val" | sed 's/'$'\x1b''/\\e/g') + esc=${esc//\'/\\\'} + setting="aCOLOUR[$key]='$esc'" + pattern="aCOLOUR\\[[^]]*${key}[^]]*\\]" + G_CONFIG_INJECT "$pattern" "$setting" "$FP_CLI" + done + } + + # Show a compact example for each editable slot and a composed preview + # that mimics how dietpi-banner uses the colours. + Show_Example() + { + echo -e "\nSample CLI color settings:" + local keys=(accent strong weak alert good highlight alt) + local j=0 + for i in "${keys[@]}"; do + ((j++)) + echo -e "$j) $i) ${COLOUR_RESET}${aCOLOUR[$i]}This is an example${COLOUR_RESET}" + done + + Make_Patterns + echo -e "\nDisplay types:" + echo -e "${aCOLOUR[strong]}HEADER${COLOUR_RESET}" + echo -e "BULLET $CLI_BULLET${COLOUR_RESET}" + echo -e "LINE $CLI_LINE${COLOUR_RESET}" + echo -e "SEPARATOR $CLI_SEPARATOR${COLOUR_RESET}" + echo -e "${COLOUR_RESET}PLAIN TEXT${COLOUR_RESET}" + echo -e "${aCOLOUR[highlight]}WARNING${COLOUR_RESET}" + echo -e "${aCOLOUR[alert]}ERROR${COLOUR_RESET}" + echo -e "${aCOLOUR[alt]}ALTERNATE${COLOUR_RESET}" + } + + # Interactive menu: choose a colour slot and select the format to apply. + # Custom value accepts common backslash-escaped forms (e.g. \e[1;32m). + Menu_Main() + { + # Load current preferences before displaying the menu. + Load_CLI_Preferences + + while true; do + Make_Patterns + Show_Example + + echo -e "\nSelect a colour to change:" + echo "0) reset all to defaults" + echo "1) accent 2) strong 3) weak 4) alert" + echo "5) good 6) highlight 7) alt" + echo -e "\ns) Save configuration to preferences file ($FP_CLI)" + echo -e "q) Quit\n" + read -rp "Selection : " selected_item + + case $selected_item in + 0) + # Restore all slots to defaults + Load_CLI_Defaults + continue + ;; + 1) key=accent ;; + 2) key=strong ;; + 3) key=weak ;; + 4) key=alert ;; + 5) key=good ;; + 6) key=highlight ;; + 7) key=alt ;; + s) + Save + echo "Saved the configuration to '$FP_CLI'" + break + ;; + q) + echo "Exiting menu..." + break + ;; + *) echo "Invalid selection. Please try again."; continue ;; + esac + + # Present predefined formats for selection (or allow custom input) + echo -e "\nChoose a predefined format for '$key', 'c' for custom, '0' for default, 'q' to cancel:" + local idx=1 fmt seq + for fmt in "${FORMAT_ORDER[@]}"; do + seq="${aFORMATS[$fmt]}" + # Print index and format name + printf "%2d) %-14s\t" "$idx" "$fmt" + # Print a small sample using printf %b to interpret escapes + echo -e "$seq" " Sample $COLOUR_RESET" + ((idx++)) + done + + echo -e "\nc) Custom sequence" + echo "0) Reset to default" + echo "q) Cancel" + read -rp "Selection: " sel + + case $sel in + q) echo "Cancelled."; continue ;; # Return to main menu + c) + read -rp "Enter custom ANSI sequence (e.g. \\e[1;32m). Empty to cancel: " newval + if [[ -z "$newval" ]]; then + echo "No change." + continue + fi + # Store interpreted escape bytes + aCOLOUR[$key]=$(printf "%b" "$newval") + echo "Updated $key (custom)." + continue + ;; + 0) + aCOLOUR[$key]="${aCOLOUR_DEFAULTS[$key]}" + echo "Reset $key to default." + ;; + *) + if [[ "$sel" =~ ^[0-9]+$ ]] && (( sel >= 1 && sel <= ${#FORMAT_ORDER[@]} )); then + sel_index=$((sel-1)) + chosen_fmt="${FORMAT_ORDER[$sel_index]}" + aCOLOUR[$key]="${aFORMATS[$chosen_fmt]}" + echo "Set $key -> $chosen_fmt" + else + echo "Invalid selection."; + fi + ;; + esac + done + } + + # If run directly, provide the simple CLI described in USAGE + if [[ "${BASH_SOURCE[0]}" == "$0" ]] + then + case $INPUT in + 0) + echo "Loading default CLI preferences..." + Load_CLI_Defaults + echo -e "${aCOLOUR[good]}Default${COLOUR_RESET} CLI preferences loaded." + Show_Example + read -rp "Save these settings to '$FP_CLI'? (y/N): " save_choice + if [[ "$save_choice" =~ ^[Yy]$ ]]; then + Save + echo "Saved the configuration to '$FP_CLI'" + fi + ;; + '') Menu_Main ;; + *) G_DIETPI-NOTIFY 1 "Invalid input \"$*\"\n\nUsage:$USAGE"; exit 1 ;; + esac + + exit 0 + fi + +} diff --git a/dietpi/func/dietpi-globals b/dietpi/func/dietpi-globals index b84cf27827..faf03d04ae 100644 --- a/dietpi/func/dietpi-globals +++ b/dietpi/func/dietpi-globals @@ -171,6 +171,40 @@ $(ps f -eo pid,user,tty,cmd | grep -i 'dietpi')" && continue } + # Truncate a string in the middle to a maximum length, inserting "..." + # - If the string is shorter than or equal to the maximum length, it is returned unchanged. + # - If the maximum length is less than 7, the string is truncated to the maximum length without adding "...". + # - E.g. + # For example: + # - `G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26` returns "Long text to... the middle" + # - `G_TRUNCATE_MID "G_TRUNCATE_MID "alphabetical" N` returns the below, as N decreases + # alp...al + # al...al + # alphab + # alpha + # ... and so on + + G_TRUNCATE_MID() + { + local s=$1 max=$2 + local len=${#s} + if (( $max >= $len )) + then + echo "$s" + return 0 + + elif (( $max < 7 )) + then + echo "${s:0:$max}" + return 0 + fi + local keep=$(( $max - 3 )) + local pre=$(( ( $keep + 1 ) / 2 )) + local suf=$(( $keep / 2 )) + local start=$(( $len - $suf )) + echo "${s:0:pre}...${s:start}" + } + # DietPi-Notify # $1: # -2 = Processing @@ -460,11 +494,12 @@ $grey───────────────────────── # - G_WHIP_NOCANCEL=1 | Optional, hide the cancel button on inputbox, menu and checkbox dialogues # - G_WHIP_MENU_ARRAY | Required for G_WHIP_MENU to set available menu entries, 2 array indices per line: ('item' 'description') # - G_WHIP_CHECKLIST_ARRAY | Required for G_WHIP_CHECKLIST set available checklist options, 3 array indices per line: ('item' 'description' 'on'/'off') + # - G_WHIP_CHECKLIST_ENUM=1 | Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags # Output: # - G_WHIP_RETURNED_VALUE | Returned value from inputbox/menu/checklist based whiptail items # G_WHIP_DESTROY | Clear vars after run of whiptail - G_WHIP_DESTROY(){ unset -v G_WHIP_DEFAULT_ITEM G_WHIP_SIZE_X_MAX G_WHIP_BUTTON_OK_TEXT G_WHIP_BUTTON_CANCEL_TEXT G_WHIP_NOCANCEL G_WHIP_MENU_ARRAY G_WHIP_CHECKLIST_ARRAY G_WHIP_INPUTBOX_REGEX G_WHIP_INPUTBOX_REGEX_TEXT; } + G_WHIP_DESTROY(){ unset -v G_WHIP_DEFAULT_ITEM G_WHIP_SIZE_X_MAX G_WHIP_BUTTON_OK_TEXT G_WHIP_BUTTON_CANCEL_TEXT G_WHIP_NOCANCEL G_WHIP_MENU_ARRAY G_WHIP_CHECKLIST_ARRAY G_WHIP_INPUTBOX_REGEX G_WHIP_INPUTBOX_REGEX_TEXT G_WHIP_CHECKLIST_ENUM; } # Run once, to be failsafe in case any exported/environment variables are left from originating shell G_WHIP_DESTROY @@ -805,6 +840,7 @@ $grey───────────────────────── # G_WHIP_CHECKLIST "message" # - Prompt user to select multiple options from G_WHIP_CHECKLIST_ARRAY and save choice to G_WHIP_RETURNED_VALUE # - Exit code: 0=selection done, else=user cancelled or noninteractive + # - G_WHIP_CHECKLIST_ENUM=1: Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags G_WHIP_CHECKLIST() { local result=1 @@ -815,10 +851,36 @@ $grey───────────────────────── local WHIP_ERROR WHIP_BACKTITLE WHIP_SCROLLTEXT WHIP_SIZE_X WHIP_SIZE_Y WHIP_SIZE_Z WHIP_MESSAGE=$* NOCANCEL=() [[ $G_WHIP_NOCANCEL == 1 ]] && NOCANCEL=('--nocancel') G_WHIP_BUTTON_OK_TEXT=${G_WHIP_BUTTON_OK_TEXT:-Confirm} + + # If G_WHIP_CHECKLIST_ENUM=1 is set, map and replace original tags and default with numeric ones + if (( $G_WHIP_CHECKLIST_ENUM )) + then + local map=() idx=0 + for ((i=0;i<${#G_WHIP_CHECKLIST_ARRAY[@]};i+=3)) + do + [[ $G_WHIP_DEFAULT_ITEM == "${G_WHIP_CHECKLIST_ARRAY[$i]}" ]] && G_WHIP_DEFAULT_ITEM=$idx + map[$idx]=${G_WHIP_CHECKLIST_ARRAY[$i]} + G_WHIP_CHECKLIST_ARRAY[$i]=$idx + ((idx++)) + done + fi + G_WHIP_INIT 3 # shellcheck disable=SC2086 G_WHIP_RETURNED_VALUE=$(whiptail ${G_PROGRAM_NAME:+--title "$G_PROGRAM_NAME"} --backtitle "$WHIP_BACKTITLE | Use spacebar to toggle selection" --checklist "$WHIP_MESSAGE" --separate-output --ok-button "$G_WHIP_BUTTON_OK_TEXT" --cancel-button "$G_WHIP_BUTTON_CANCEL_TEXT" "${NOCANCEL[@]}" --default-item "$G_WHIP_DEFAULT_ITEM" $WHIP_SCROLLTEXT "$WHIP_SIZE_Y" "$WHIP_SIZE_X" "$WHIP_SIZE_Z" -- "${G_WHIP_CHECKLIST_ARRAY[@]}" 3>&1 1>&2 2>&3-) result=$? + + # If G_WHIP_CHECKLIST_ENUM=1 is set, replace numeric selections back to original tags + if (( $G_WHIP_CHECKLIST_ENUM )) + then + local mapped=() + for idx in $G_WHIP_RETURNED_VALUE + do + mapped+=("${map[$idx]}") + done + G_WHIP_RETURNED_VALUE=${mapped[*]} + fi + G_WHIP_RETURNED_VALUE=$(echo -e "$G_WHIP_RETURNED_VALUE" | tr '\n' ' ') else G_WHIP_RETURNED_VALUE=$G_WHIP_DEFAULT_ITEM