Some checks failed
Check / eval (push) Has been cancelled
Weekly nomarchy-gen-prune deletes only generations that are both older than 14 days and beyond the three most recent past gens (current always kept), for system and Home Manager profiles. Stock nix.gc no longer uses --delete-older-than. checks.gen-prune covers selection self-test and unit wiring; doctor points at the new tool when disk is tight.
440 lines
19 KiB
Bash
440 lines
19 KiB
Bash
# nomarchy-doctor — one-shot, READ-ONLY health check: the things that
|
||
# actually break user machines, as a pass/fail sheet where every
|
||
# failure prints the one command that fixes it. It never changes
|
||
# anything itself. Exit 0 = no failures (warnings allowed), 1 = at
|
||
# least one ✖.
|
||
|
||
flake="${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||
fails=0
|
||
warns=0
|
||
|
||
grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
|
||
ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; }
|
||
bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1"
|
||
printf ' %bfix: %s%b\n' "$dim" "$2" "$rst"
|
||
fails=$((fails + 1)); }
|
||
warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1"
|
||
if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi
|
||
warns=$((warns + 1)); }
|
||
skip() { printf ' %b– %s%b\n' "$dim" "$1" "$rst"; }
|
||
|
||
printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')"
|
||
|
||
# ── systemd units ────────────────────────────────────────────────────
|
||
mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||
if [ "${#sysfailed[@]}" -eq 0 ]; then
|
||
ok "no failed system units"
|
||
else
|
||
bad "failed system unit(s): ${sysfailed[*]}" \
|
||
"journalctl -b -u <unit> (read why; sys-rebuild after fixing)"
|
||
fi
|
||
|
||
# A user bus only exists inside a session (not over bare SSH/root).
|
||
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
|
||
mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||
if [ "${#usrfailed[@]}" -eq 0 ]; then
|
||
ok "no failed user units"
|
||
else
|
||
bad "failed user unit(s): ${usrfailed[*]}" \
|
||
"journalctl --user -b -u <unit>"
|
||
fi
|
||
else
|
||
skip "user units (no user session bus here)"
|
||
fi
|
||
|
||
# ── disk space ───────────────────────────────────────────────────────
|
||
# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of
|
||
# live systems and test VMs (where usage numbers mean nothing).
|
||
seen=""
|
||
for mp in / /boot /nix; do
|
||
[ -d "$mp" ] || continue
|
||
line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}')
|
||
[ -n "$line" ] || continue
|
||
read -r dev fstype use availkb <<<"$line"
|
||
case "$fstype" in
|
||
ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;;
|
||
*) continue ;;
|
||
esac
|
||
case " $seen " in *" $dev "*) continue ;; esac
|
||
seen="$seen $dev"
|
||
avail=$(numfmt --to=iec $((availkb * 1024)))
|
||
if [ "$use" -ge 90 ]; then
|
||
bad "$mp is ${use}% full (${avail} free)" \
|
||
"sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
|
||
else
|
||
ok "$mp has space (${use}% used, ${avail} free)"
|
||
fi
|
||
done
|
||
|
||
# ── the flake + state file ───────────────────────────────────────────
|
||
# #107: prefer state.json; theme-state.json still counts until migrated.
|
||
state_file=
|
||
if [ -f "$flake/state.json" ]; then
|
||
state_file="$flake/state.json"
|
||
elif [ -f "$flake/theme-state.json" ]; then
|
||
state_file="$flake/theme-state.json"
|
||
fi
|
||
if [ -n "$state_file" ]; then
|
||
state_base=$(basename "$state_file")
|
||
if jq empty "$state_file" 2>/dev/null; then
|
||
ok "$state_base parses"
|
||
else
|
||
bad "$state_base is not valid JSON (rebuilds will fail)" \
|
||
"nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
|
||
fi
|
||
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
|
||
ok "$state_base is git-tracked"
|
||
else
|
||
bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
|
||
"git -C $flake add $state_base"
|
||
fi
|
||
if [ "$state_base" = "theme-state.json" ]; then
|
||
warn "state file still named theme-state.json — run any menu write or" \
|
||
"nomarchy-state-sync set … to migrate to state.json"
|
||
fi
|
||
else
|
||
skip "state.json (no flake checkout at $flake)"
|
||
fi
|
||
|
||
if [ -d "$flake/.git" ]; then
|
||
dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l)
|
||
if [ "$dirty" -gt 0 ]; then
|
||
warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \
|
||
"commit when happy: git -C $flake add -A && git -C $flake commit -m settings"
|
||
else
|
||
ok "flake checkout is clean"
|
||
fi
|
||
behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
|
||
if [ "$behind" -gt 0 ]; then
|
||
warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update"
|
||
fi
|
||
fi
|
||
|
||
# ── generation age ───────────────────────────────────────────────────
|
||
# The profile SYMLINK's own mtime is the generation's creation time
|
||
# (store paths themselves are all epoch-1).
|
||
link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)
|
||
if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then
|
||
ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0)
|
||
days=$(( ($(date +%s) - ts) / 86400 ))
|
||
if [ "$days" -gt 30 ]; then
|
||
warn "last system rebuild was $days days ago" \
|
||
"sys-update when convenient (security fixes arrive with input bumps)"
|
||
else
|
||
ok "system generation is $days day(s) old"
|
||
fi
|
||
else
|
||
skip "system generation age (no system profile)"
|
||
fi
|
||
|
||
# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ─────────
|
||
if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then
|
||
if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then
|
||
ok "generation prune timer is enabled (14d, keep ≥3 past)"
|
||
else
|
||
bad "nomarchy-gen-prune.timer is installed but not enabled" \
|
||
"systemctl enable --now nomarchy-gen-prune.timer"
|
||
fi
|
||
elif command -v nomarchy-gen-prune >/dev/null 2>&1; then
|
||
warn "nomarchy-gen-prune is on PATH but no timer unit" \
|
||
"rebuild the system so gen-prune.nix ships the weekly timer"
|
||
else
|
||
skip "generation prune (package not on this machine)"
|
||
fi
|
||
|
||
# ── snapper timeline (when enabled) ──────────────────────────────────
|
||
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
|
||
if systemctl is-active --quiet snapper-timeline.timer; then
|
||
ok "snapper timeline snapshots are running"
|
||
else
|
||
bad "snapper is enabled but the timeline timer is not active" \
|
||
"systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)"
|
||
fi
|
||
else
|
||
skip "snapper (not enabled on this machine)"
|
||
fi
|
||
|
||
# ── first-boot HM pre-activate (installer fail flag, BACKLOG #83) ────
|
||
# Installer writes /var/log/nomarchy-hm-preactivate.log on the target.
|
||
# If that log exists and there is still no Home Manager generation, the
|
||
# desktop never baked — print the recovery one-liner. Override log path
|
||
# with NOMARCHY_HM_PREACTIVATE_LOG for checks.doctor.
|
||
pre_log="${NOMARCHY_HM_PREACTIVATE_LOG:-/var/log/nomarchy-hm-preactivate.log}"
|
||
hm_gen_present=0
|
||
uid_name="${USER:-$(id -un 2>/dev/null || echo)}"
|
||
for d in \
|
||
"${XDG_STATE_HOME:-${HOME:-}/.local/state}/nix/profiles" \
|
||
"/nix/var/nix/profiles/per-user/${uid_name}"; do
|
||
[ -n "$d" ] || continue
|
||
if [ -e "$d/home-manager" ] \
|
||
|| ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
|
||
hm_gen_present=1
|
||
break
|
||
fi
|
||
done
|
||
if [ ! -r "$pre_log" ]; then
|
||
skip "first-boot pre-activate (no installer log — not a failed bake)"
|
||
elif [ "$hm_gen_present" -eq 1 ]; then
|
||
ok "first-boot pre-activate: desktop generation is present"
|
||
else
|
||
bad "desktop was not pre-activated at install (no Home Manager generation)" \
|
||
"home-manager switch --flake ~/.nomarchy -b bak (details: $pre_log)"
|
||
fi
|
||
|
||
# ══ hardware ═════════════════════════════════════════════════════════
|
||
# Every check below self-gates: it skips cleanly when the tool, service,
|
||
# or device isn't present, so the section shrinks to fit the machine and
|
||
# never fails just because a feature is absent. Still read-only.
|
||
|
||
# ── network (NetworkManager) ─────────────────────────────────────────
|
||
if command -v nmcli >/dev/null 2>&1; then
|
||
nmstate=$(nmcli -t -f STATE general status 2>/dev/null || true)
|
||
case "$nmstate" in
|
||
connected*) ok "NetworkManager: $nmstate" ;;
|
||
"") skip "NetworkManager (no state reported)" ;;
|
||
*) warn "NetworkManager state: $nmstate" \
|
||
"connect in System › Network (or: nmcli device)" ;;
|
||
esac
|
||
else
|
||
skip "NetworkManager (nmcli not present)"
|
||
fi
|
||
|
||
# ── default audio sink (PipeWire) ────────────────────────────────────
|
||
# wpctl/pactl talk to the user's PipeWire session — only meaningful with
|
||
# a user bus (not over bare SSH/root).
|
||
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
|
||
if command -v wpctl >/dev/null 2>&1; then
|
||
if wpctl inspect @DEFAULT_AUDIO_SINK@ >/dev/null 2>&1; then
|
||
ok "default audio sink present"
|
||
else
|
||
warn "no default PipeWire sink" "pick one in System › Audio (or: wpctl status)"
|
||
fi
|
||
elif command -v pactl >/dev/null 2>&1; then
|
||
defsink=$(pactl get-default-sink 2>/dev/null || true)
|
||
if [ -n "$defsink" ] && [ "$defsink" != "@DEFAULT_SINK@" ]; then
|
||
ok "default audio sink: $defsink"
|
||
else
|
||
warn "no default audio sink" "pick one in System › Audio"
|
||
fi
|
||
else
|
||
skip "audio sink (no wpctl/pactl)"
|
||
fi
|
||
else
|
||
skip "audio sink (no user session bus here)"
|
||
fi
|
||
|
||
# ── GPU acceleration smoke (only if the probes are installed) ────────
|
||
if command -v vainfo >/dev/null 2>&1; then
|
||
if vainfo >/dev/null 2>&1; then
|
||
ok "VA-API acceleration works (vainfo)"
|
||
else
|
||
warn "vainfo present but VA-API probe failed" \
|
||
"check mkFlake.hardwareProfile / GPU drivers (or run: vainfo)"
|
||
fi
|
||
else
|
||
skip "VA-API smoke (vainfo not installed)"
|
||
fi
|
||
if command -v glxinfo >/dev/null 2>&1; then
|
||
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
|
||
if glxinfo -B >/dev/null 2>&1; then
|
||
ok "OpenGL renderer responds (glxinfo)"
|
||
else
|
||
warn "glxinfo present but GL probe failed" \
|
||
"check mkFlake.hardwareProfile / GPU drivers (or run: glxinfo -B)"
|
||
fi
|
||
else
|
||
skip "OpenGL smoke (no display attached)"
|
||
fi
|
||
else
|
||
skip "OpenGL smoke (glxinfo not installed)"
|
||
fi
|
||
|
||
# ── fingerprint (only when fprintd is enabled) ───────────────────────
|
||
if [ -n "$(systemctl list-unit-files fprintd.service --no-legend --plain 2>/dev/null || true)" ]; then
|
||
enrolled=""
|
||
if command -v fprintd-list >/dev/null 2>&1 && [ -n "${USER:-}" ]; then
|
||
enrolled=$(fprintd-list "$USER" 2>/dev/null || true)
|
||
fi
|
||
if [ -n "$enrolled" ] && ! printf '%s' "$enrolled" | grep -qi 'no fingers'; then
|
||
ok "fprintd enabled, fingerprint(s) enrolled for ${USER:-user}"
|
||
elif [ -n "$enrolled" ]; then
|
||
warn "fprintd enabled but no fingerprints enrolled" "enroll one: fprintd-enroll"
|
||
else
|
||
ok "fprintd unit installed (fingerprint enabled)"
|
||
fi
|
||
else
|
||
skip "fingerprint (fprintd not enabled)"
|
||
fi
|
||
|
||
# ── firmware updates (fwupd) ─────────────────────────────────────────
|
||
# fwupd is D-Bus-activated, so an idle daemon is normal, not a fault.
|
||
# get-updates reads local metadata (no flashing); a hit is a soft warn.
|
||
if command -v fwupdmgr >/dev/null 2>&1 \
|
||
&& [ -n "$(systemctl list-unit-files fwupd.service --no-legend --plain 2>/dev/null || true)" ]; then
|
||
if systemctl is-active --quiet fwupd.service; then
|
||
ok "fwupd daemon active"
|
||
else
|
||
skip "fwupd daemon (idle — D-Bus-activated on demand)"
|
||
fi
|
||
if timeout 20 fwupdmgr get-updates >/dev/null 2>&1; then
|
||
warn "firmware updates are available" "review, then apply: fwupdmgr update"
|
||
else
|
||
ok "firmware up to date (no pending updates)"
|
||
fi
|
||
else
|
||
skip "fwupd (not enabled / fwupdmgr absent)"
|
||
fi
|
||
|
||
# ── laptop battery charge threshold (only when a limit is set) ───────
|
||
# Name-agnostic (BAT0, CMB0, …) — same type/scope filter as notify (#60).
|
||
# Dell: Adaptive mode ignores end_threshold while still reporting the
|
||
# written value — warn if type is not Custom when a limit is active.
|
||
bat_seen=0; bat_limited=0
|
||
for bat in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
f="$bat/charge_control_end_threshold"
|
||
[ -r "$f" ] || continue
|
||
bat_seen=1
|
||
lim=$(cat "$f" 2>/dev/null || true)
|
||
case "$lim" in
|
||
''|*[!0-9]*) continue ;;
|
||
esac
|
||
if [ "$lim" -lt 100 ]; then
|
||
bat_limited=1
|
||
ctype=$(cat "$bat/charge_types" 2>/dev/null || cat "$bat/charge_type" 2>/dev/null || true)
|
||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||
name=$(basename "$bat")
|
||
if [ -n "$active" ] && [ "$active" != Custom ]; then
|
||
warn "$name charge limit ${lim}% but type is $active (not Custom)" \
|
||
"thresholds only apply in Custom — run: systemctl restart nomarchy-battery-charge-limit"
|
||
else
|
||
ok "$name charge limit active at ${lim}%${active:+ (type $active)}"
|
||
fi
|
||
fi
|
||
done
|
||
if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
|
||
skip "battery charge limit (none set — charges to 100%)"
|
||
fi
|
||
|
||
# ── battery health (report-only, BACKLOG #80) ────────────────────────
|
||
# cycle_count + retained capacity (charge_* µAh or energy_* µWh). Same
|
||
# system-battery filter as charge-limit/notify. Self-gates when no
|
||
# battery or the firmware omits the attrs (desktops, bare VMs). Override
|
||
# the sysfs root with NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
|
||
# fixture (test_power has no cycle/design attrs).
|
||
ps_root="${NOMARCHY_POWER_SUPPLY_ROOT:-/sys/class/power_supply}"
|
||
bat_health_seen=0
|
||
for bat in "$ps_root"/*/; do
|
||
[ -d "$bat" ] || continue
|
||
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
name=$(basename "$bat")
|
||
|
||
cycles=$(cat "$bat/cycle_count" 2>/dev/null || true)
|
||
case "$cycles" in ''|*[!0-9]*) cycles= ;; esac
|
||
# Some firmwares export 0 forever — treat as unknown, not "brand new".
|
||
if [ -n "$cycles" ] && [ "$cycles" -eq 0 ]; then cycles=; fi
|
||
|
||
full=""; design=""
|
||
if [ -r "$bat/charge_full" ] && [ -r "$bat/charge_full_design" ]; then
|
||
full=$(cat "$bat/charge_full" 2>/dev/null || true)
|
||
design=$(cat "$bat/charge_full_design" 2>/dev/null || true)
|
||
elif [ -r "$bat/energy_full" ] && [ -r "$bat/energy_full_design" ]; then
|
||
full=$(cat "$bat/energy_full" 2>/dev/null || true)
|
||
design=$(cat "$bat/energy_full_design" 2>/dev/null || true)
|
||
fi
|
||
case "$full" in ''|*[!0-9]*) full= ;; esac
|
||
case "$design" in ''|*[!0-9]*) design= ;; esac
|
||
pct=""
|
||
if [ -n "$full" ] && [ -n "$design" ] && [ "$design" -gt 0 ]; then
|
||
pct=$(( full * 100 / design ))
|
||
fi
|
||
|
||
[ -n "$cycles" ] || [ -n "$pct" ] || continue
|
||
bat_health_seen=1
|
||
|
||
detail=""
|
||
[ -n "$cycles" ] && detail="${cycles} cycles"
|
||
if [ -n "$pct" ]; then
|
||
[ -n "$detail" ] && detail="$detail, "
|
||
detail="${detail}${pct}% of design capacity"
|
||
fi
|
||
# Soft warn only — wear isn't a doctor "fix", just a heads-up.
|
||
if [ -n "$pct" ] && [ "$pct" -lt 70 ]; then
|
||
warn "$name health: $detail" \
|
||
"battery wear is normal over years — replace when runtime suffers"
|
||
else
|
||
ok "$name health: $detail"
|
||
fi
|
||
done
|
||
if [ "$bat_health_seen" -eq 0 ]; then
|
||
skip "battery health (no system battery / no cycle or design capacity attrs)"
|
||
fi
|
||
|
||
# ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
|
||
# Read-only: is there a working hibernate path? zram is RAM-only and can't
|
||
# hold a resume image, so hibernation needs a disk swap (partition or file)
|
||
# PLUS resume= wiring; a swapfile additionally needs resume_offset. A
|
||
# swap=0 machine opts out cleanly and this section skips. Never fails the
|
||
# sheet — an absent or under-sized swap is advisory, not a running fault.
|
||
# (|| echo 0 keeps set -euo pipefail happy if a proc file is unreadable.)
|
||
disk_swap_kb=$(awk 'NR>1 && $1 !~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
|
||
zram_kb=$(awk 'NR>1 && $1 ~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
|
||
ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||
|
||
if [ "${disk_swap_kb:-0}" -gt 0 ]; then
|
||
if grep -q 'resume=' /proc/cmdline 2>/dev/null; then
|
||
ok "hibernate: resume device set on the kernel cmdline"
|
||
# A swapfile also needs resume_offset to locate the image within it.
|
||
if awk 'NR>1 && $1 !~ /zram/ && $2=="file"{f=1} END{exit !f}' /proc/swaps 2>/dev/null \
|
||
&& ! grep -q 'resume_offset=' /proc/cmdline 2>/dev/null; then
|
||
warn "swapfile in use but no resume_offset= on the cmdline — resume will fail" \
|
||
"add boot.kernelParams resume_offset (docs/MIGRATION.md → Enabling hibernation)"
|
||
fi
|
||
else
|
||
warn "disk swap present but no resume= on the kernel cmdline — hibernate won't resume" \
|
||
"set boot.resumeDevice (docs/MIGRATION.md → Enabling hibernation)"
|
||
fi
|
||
if [ "${ram_kb:-0}" -gt 0 ] && [ "$disk_swap_kb" -lt "$ram_kb" ]; then
|
||
warn "disk swap ($(numfmt --to=iec $((disk_swap_kb * 1024)))) is smaller than RAM ($(numfmt --to=iec $((ram_kb * 1024)))) — a full hibernate image may not fit" \
|
||
"size swap ≥ RAM (docs/MIGRATION.md → Enabling hibernation)"
|
||
else
|
||
ok "hibernate: disk swap ≥ RAM ($(numfmt --to=iec $((disk_swap_kb * 1024))))"
|
||
fi
|
||
else
|
||
skip "hibernate (no disk swap — swap=0 opt-out; zram alone can't resume)"
|
||
fi
|
||
|
||
# zram compressed-RAM swap — the memory-pressure layer oom.nix ships on
|
||
# by default (#76). Its absence on a Nomarchy box means something is off.
|
||
if [ "${zram_kb:-0}" -gt 0 ]; then
|
||
ok "zram compressed-RAM swap active ($(numfmt --to=iec $((zram_kb * 1024))))"
|
||
else
|
||
warn "zram swap not active (the default memory-pressure layer)" \
|
||
"expected on by default — check modules/nixos/oom.nix, then sys-rebuild"
|
||
fi
|
||
|
||
# Best-effort: a suspend/hibernate failure recorded in the previous boot's
|
||
# kernel log. Needs journal read access; silently no-ops without it. The
|
||
# `|| true` keeps a no-match grep / missing -1 boot from tripping set -e.
|
||
if command -v journalctl >/dev/null 2>&1; then
|
||
pmerr=$(journalctl -b -1 -k --no-pager 2>/dev/null \
|
||
| grep -iE 'PM: .*(hibernat|suspend).*(fail|error)|Failed to (hibernate|suspend)' \
|
||
| tail -n1 || true)
|
||
if [ -n "$pmerr" ]; then
|
||
warn "a suspend/hibernate error is in the previous boot's log" \
|
||
"review: journalctl -b -1 -k -g 'hibernat|suspend'"
|
||
fi
|
||
fi
|
||
|
||
# ── verdict ──────────────────────────────────────────────────────────
|
||
echo
|
||
if [ "$fails" -eq 0 ]; then
|
||
printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns"
|
||
exit 0
|
||
else
|
||
printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns"
|
||
exit 1
|
||
fi
|