feat: add live outage countdown cli dashboard
This commit is contained in:
@@ -21,6 +21,11 @@ jobs:
|
||||
- name: Check out repository
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Test standalone Bash CLI
|
||||
run: |
|
||||
bash -n main.sh tests/test_main.sh
|
||||
bash tests/test_main.sh
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
|
||||
40
README.md
40
README.md
@@ -169,3 +169,43 @@ Tests mock both external services and never send real messages.
|
||||
|
||||
Historical state from the previous implementation is retained at
|
||||
`archive/blackouts-1404.json` for reference only and is not imported.
|
||||
|
||||
## Standalone outage dashboard
|
||||
|
||||
`main.sh` is a read-only terminal dashboard for the outage state committed to
|
||||
this public repository. It downloads the latest `state/outages.json` directly
|
||||
from Gitea, so it can be copied and run without cloning the project or installing
|
||||
the Python application.
|
||||
|
||||
It requires Bash, `curl`, `jq`, and GNU `date`:
|
||||
|
||||
```sh
|
||||
./main.sh
|
||||
```
|
||||
|
||||
The display uses a flicker-free alternate terminal screen and runs until `q` is
|
||||
pressed (or it is interrupted with `Ctrl+C`). It updates the countdown every
|
||||
second and refetches the committed state every 10 minutes. Set a different whole
|
||||
minute interval with `-i`/`--interval`:
|
||||
|
||||
```sh
|
||||
./main.sh --interval 5
|
||||
```
|
||||
|
||||
The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the outage
|
||||
currently in progress or the nearest upcoming outage. A compact schedule below it
|
||||
lists all active current and future events, including emergency outages. The
|
||||
terminal output uses English-only labels and omits the stored address and reason
|
||||
text. When there are no qualifying events it prints an `ALL CLEAR` screen and exits
|
||||
successfully.
|
||||
|
||||
Colors are enabled only when output is attached to a terminal. Set `NO_COLOR` to
|
||||
disable them explicitly, or set `COLUMNS` to control the rendered width:
|
||||
|
||||
```sh
|
||||
NO_COLOR=1 COLUMNS=80 ./main.sh
|
||||
```
|
||||
|
||||
When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it
|
||||
automatically. Otherwise the bundled matching glyphs are used, keeping the same
|
||||
appearance without making Figlet a dependency.
|
||||
|
||||
895
main.sh
Executable file
895
main.sh
Executable file
@@ -0,0 +1,895 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly STATE_URL="https://mahgit.ir/MeghdadFadaee/daily-blackout-check/raw/branch/main/state/outages.json"
|
||||
readonly TEHRAN_TZ="Asia/Tehran"
|
||||
|
||||
STATE_PATH=""
|
||||
DOWNLOAD_PATH=""
|
||||
REFRESH_STATUS_PATH=""
|
||||
REFRESH_PID=""
|
||||
declare -a SCHEDULE=()
|
||||
HERO=""
|
||||
REFRESH_ERROR=""
|
||||
TERMINAL_ACTIVE=0
|
||||
|
||||
cleanup() {
|
||||
if ((TERMINAL_ACTIVE)); then
|
||||
printf '\033[?25h\033[?1049l'
|
||||
fi
|
||||
if [[ -n "$REFRESH_PID" ]]; then
|
||||
kill "$REFRESH_PID" 2>/dev/null || true
|
||||
wait "$REFRESH_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$STATE_PATH" && -f "$STATE_PATH" ]]; then
|
||||
rm -f -- "$STATE_PATH"
|
||||
fi
|
||||
if [[ -n "$DOWNLOAD_PATH" && -f "$DOWNLOAD_PATH" ]]; then
|
||||
rm -f -- "$DOWNLOAD_PATH"
|
||||
fi
|
||||
if [[ -n "$REFRESH_STATUS_PATH" && -f "$REFRESH_STATUS_PATH" ]]; then
|
||||
rm -f -- "$REFRESH_STATUS_PATH"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./main.sh [-i MINUTES]
|
||||
|
||||
Continuously display current and upcoming electricity outages from the public
|
||||
repository state. The countdown updates every second.
|
||||
|
||||
Options:
|
||||
-i, --interval MINUTES Refetch state at this interval (default: 10)
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
NO_COLOR Disable ANSI colors when set
|
||||
COLUMNS Override the detected output width
|
||||
|
||||
Required commands: bash, curl, jq, and GNU date
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
die "missing required command: $command_name"
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_state() {
|
||||
local destination="$1"
|
||||
local cache_key="$2"
|
||||
|
||||
curl -fsSL --compressed \
|
||||
--connect-timeout 10 \
|
||||
--max-time 30 \
|
||||
-H 'Cache-Control: no-cache' \
|
||||
-o "$destination" \
|
||||
"${STATE_URL}?cli=${cache_key}"
|
||||
}
|
||||
|
||||
start_state_refresh() {
|
||||
local cache_key="$1"
|
||||
|
||||
: >"$REFRESH_STATUS_PATH"
|
||||
(
|
||||
if ! fetch_state "$DOWNLOAD_PATH" "$cache_key" 2>/dev/null; then
|
||||
printf 'download-error\n' >"$REFRESH_STATUS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
if ! validate_state "$DOWNLOAD_PATH"; then
|
||||
printf 'validation-error\n' >"$REFRESH_STATUS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
printf 'ok\n' >"$REFRESH_STATUS_PATH"
|
||||
) &
|
||||
REFRESH_PID="$!"
|
||||
}
|
||||
|
||||
finish_state_refresh() {
|
||||
local result
|
||||
|
||||
if [[ -z "$REFRESH_PID" || ! -s "$REFRESH_STATUS_PATH" ]]; then
|
||||
return 1
|
||||
fi
|
||||
wait "$REFRESH_PID" 2>/dev/null || true
|
||||
REFRESH_PID=""
|
||||
result="$(<"$REFRESH_STATUS_PATH")"
|
||||
|
||||
case "$result" in
|
||||
ok)
|
||||
if ! cp -- "$DOWNLOAD_PATH" "$STATE_PATH"; then
|
||||
REFRESH_ERROR="Could not cache the downloaded state."
|
||||
return 1
|
||||
fi
|
||||
REFRESH_ERROR=""
|
||||
return 0
|
||||
;;
|
||||
validation-error)
|
||||
REFRESH_ERROR="Downloaded state is malformed; showing the last valid state."
|
||||
;;
|
||||
*)
|
||||
REFRESH_ERROR="State refresh failed; showing the last valid state."
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_state() {
|
||||
local path="$1"
|
||||
|
||||
jq -e '
|
||||
type == "object"
|
||||
and .schema_version == 1
|
||||
and (.bills | type == "object")
|
||||
and all(
|
||||
.bills | to_entries[];
|
||||
(.key | type == "string")
|
||||
and (.value | type == "object")
|
||||
and (.value.events | type == "object")
|
||||
and all(
|
||||
.value.events | to_entries[];
|
||||
(.key | type == "string")
|
||||
and (.value | type == "object")
|
||||
and (.value.status == "active" or .value.status == "cancelled")
|
||||
and (.value.snapshot | type == "object")
|
||||
and (.value.snapshot.outage_number | type == "string")
|
||||
and .value.snapshot.outage_number == .key
|
||||
and (.value.snapshot.outage_date | type == "string")
|
||||
and (.value.snapshot.start_time | type == "string")
|
||||
and (.value.snapshot.stop_time | type == "string")
|
||||
and (.value.snapshot.address | type == "string")
|
||||
and (.value.snapshot.reason | type == "string")
|
||||
and (.value.snapshot.is_planned | type == "boolean")
|
||||
)
|
||||
)
|
||||
' "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Convert a Jalali YYYY/MM/DD date to Gregorian YYYY-MM-DD. This arithmetic
|
||||
# conversion covers the modern Jalali range used by the outage service.
|
||||
jalali_to_gregorian() {
|
||||
local input="$1"
|
||||
local jy jm jd days gy gd gm leap index
|
||||
local -a month_days=(0 31 28 31 30 31 30 31 31 30 31 30 31)
|
||||
|
||||
if [[ ! "$input" =~ ^([0-9]{4})/([0-9]{2})/([0-9]{2})$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
jy=$((10#${BASH_REMATCH[1]}))
|
||||
jm=$((10#${BASH_REMATCH[2]}))
|
||||
jd=$((10#${BASH_REMATCH[3]}))
|
||||
if ((jy < 1 || jm < 1 || jm > 12 || jd < 1)); then
|
||||
return 1
|
||||
fi
|
||||
if ((jm <= 6 && jd > 31)); then
|
||||
return 1
|
||||
fi
|
||||
if ((jm >= 7 && jd > 30)); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
jy=$((jy + 1595))
|
||||
days=$((-355668 + (365 * jy) + ((jy / 33) * 8) + (((jy % 33) + 3) / 4) + jd))
|
||||
if ((jm < 7)); then
|
||||
days=$((days + ((jm - 1) * 31)))
|
||||
else
|
||||
days=$((days + ((jm - 7) * 30) + 186))
|
||||
fi
|
||||
|
||||
gy=$((400 * (days / 146097)))
|
||||
days=$((days % 146097))
|
||||
if ((days > 36524)); then
|
||||
days=$((days - 1))
|
||||
gy=$((gy + (100 * (days / 36524))))
|
||||
days=$((days % 36524))
|
||||
if ((days >= 365)); then
|
||||
days=$((days + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
gy=$((gy + (4 * (days / 1461))))
|
||||
days=$((days % 1461))
|
||||
if ((days > 365)); then
|
||||
gy=$((gy + ((days - 1) / 365)))
|
||||
days=$(((days - 1) % 365))
|
||||
fi
|
||||
|
||||
gd=$((days + 1))
|
||||
leap=0
|
||||
if (((gy % 4 == 0 && gy % 100 != 0) || gy % 400 == 0)); then
|
||||
leap=1
|
||||
fi
|
||||
month_days[2]=$((28 + leap))
|
||||
|
||||
gm=1
|
||||
for index in {1..12}; do
|
||||
if ((gd <= month_days[index])); then
|
||||
gm=$index
|
||||
break
|
||||
fi
|
||||
gd=$((gd - month_days[index]))
|
||||
done
|
||||
|
||||
printf '%04d-%02d-%02d\n' "$gy" "$gm" "$gd"
|
||||
}
|
||||
|
||||
event_epochs() {
|
||||
local outage_date="$1"
|
||||
local start_time="$2"
|
||||
local stop_time="$3"
|
||||
local gregorian start_epoch stop_epoch
|
||||
|
||||
if [[ ! "$start_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \
|
||||
|| ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$stop_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \
|
||||
|| ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
gregorian="$(jalali_to_gregorian "$outage_date")" || return 1
|
||||
start_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $start_time:00" +%s 2>/dev/null)" \
|
||||
|| return 1
|
||||
stop_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $stop_time:00" +%s 2>/dev/null)" \
|
||||
|| return 1
|
||||
if ((stop_epoch <= start_epoch)); then
|
||||
stop_epoch=$((stop_epoch + 86400))
|
||||
fi
|
||||
|
||||
printf '%s %s\n' "$start_epoch" "$stop_epoch"
|
||||
}
|
||||
|
||||
event_records() {
|
||||
local path="$1"
|
||||
|
||||
jq -c '
|
||||
.bills
|
||||
| to_entries[] as $bill
|
||||
| $bill.value.events
|
||||
| to_entries[]
|
||||
| {
|
||||
bill_id: $bill.key,
|
||||
outage_number: .key,
|
||||
status: .value.status,
|
||||
outage_date: .value.snapshot.outage_date,
|
||||
start_time: .value.snapshot.start_time,
|
||||
stop_time: .value.snapshot.stop_time,
|
||||
address: .value.snapshot.address,
|
||||
reason: .value.snapshot.reason,
|
||||
is_planned: .value.snapshot.is_planned
|
||||
}
|
||||
' "$path"
|
||||
}
|
||||
|
||||
record_fields() {
|
||||
local record="$1"
|
||||
local -n output="$2"
|
||||
|
||||
mapfile -d '' -t output < <(
|
||||
jq -j '
|
||||
[
|
||||
.bill_id,
|
||||
.outage_number,
|
||||
.status,
|
||||
.outage_date,
|
||||
.start_time,
|
||||
.stop_time,
|
||||
.address,
|
||||
.reason,
|
||||
(.is_planned | tostring)
|
||||
][] | ., "\u0000"
|
||||
' <<<"$record"
|
||||
)
|
||||
}
|
||||
|
||||
build_schedule() {
|
||||
local path="$1"
|
||||
local now_epoch="$2"
|
||||
local record start_epoch stop_epoch phase phase_order sort_key line hero_key=""
|
||||
local -a fields sortable=()
|
||||
|
||||
SCHEDULE=()
|
||||
HERO=""
|
||||
|
||||
while IFS= read -r record; do
|
||||
record_fields "$record" fields
|
||||
if [[ "${fields[2]}" != "active" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! read -r start_epoch stop_epoch < <(
|
||||
event_epochs "${fields[3]}" "${fields[4]}" "${fields[5]}"
|
||||
); then
|
||||
printf 'Invalid outage date or time for event %s\n' "${fields[1]}" >&2
|
||||
return 1
|
||||
fi
|
||||
if ((stop_epoch <= now_epoch)); then
|
||||
continue
|
||||
fi
|
||||
|
||||
if ((start_epoch <= now_epoch)); then
|
||||
phase="ONGOING"
|
||||
phase_order=0
|
||||
sort_key="$(printf '0:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")"
|
||||
if [[ -z "$hero_key" || "$stop_epoch" -lt "$hero_key" ]]; then
|
||||
hero_key="$stop_epoch"
|
||||
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
||||
fi
|
||||
else
|
||||
phase="UPCOMING"
|
||||
phase_order=1
|
||||
sort_key="$(printf '1:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")"
|
||||
if [[ -z "$hero_key" && -z "$HERO" ]]; then
|
||||
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
||||
elif [[ "${HERO%%$'\t'*}" == "UPCOMING" ]]; then
|
||||
local current_hero_start
|
||||
current_hero_start="${HERO#*$'\t'}"
|
||||
current_hero_start="${current_hero_start%%$'\t'*}"
|
||||
if ((start_epoch < current_hero_start)); then
|
||||
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
line="$sort_key"$'\t'"$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
||||
sortable+=("$line")
|
||||
done < <(event_records "$path")
|
||||
|
||||
if ((${#sortable[@]})); then
|
||||
mapfile -t SCHEDULE < <(printf '%s\n' "${sortable[@]}" | LC_ALL=C sort)
|
||||
fi
|
||||
}
|
||||
|
||||
terminal_columns() {
|
||||
local columns="${COLUMNS:-}"
|
||||
|
||||
if [[ -z "$columns" && -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
||||
columns="$(tput cols 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ ! "$columns" =~ ^[0-9]+$ || "$columns" -lt 40 ]]; then
|
||||
columns=80
|
||||
fi
|
||||
printf '%s\n' "$columns"
|
||||
}
|
||||
|
||||
color_setup() {
|
||||
RESET=""
|
||||
BOLD=""
|
||||
DIM=""
|
||||
CYAN=""
|
||||
GREEN=""
|
||||
YELLOW=""
|
||||
RED=""
|
||||
|
||||
if ((TERMINAL_ACTIVE)) && [[ -z "${NO_COLOR+x}" ]]; then
|
||||
RESET=$'\033[0m'
|
||||
BOLD=$'\033[1m'
|
||||
DIM=$'\033[2m'
|
||||
CYAN=$'\033[36m'
|
||||
GREEN=$'\033[32m'
|
||||
YELLOW=$'\033[33m'
|
||||
RED=$'\033[31m'
|
||||
fi
|
||||
}
|
||||
|
||||
center_line() {
|
||||
local text="$1"
|
||||
local columns="$2"
|
||||
local padding=0
|
||||
|
||||
if ((${#text} < columns)); then
|
||||
padding=$(((columns - ${#text}) / 2))
|
||||
fi
|
||||
printf '%*s%s\n' "$padding" '' "$text"
|
||||
}
|
||||
|
||||
repeat_character() {
|
||||
local character="$1"
|
||||
local count="$2"
|
||||
local output=""
|
||||
|
||||
printf -v output '%*s' "$count" ''
|
||||
printf '%s' "${output// /$character}"
|
||||
}
|
||||
|
||||
large_glyph() {
|
||||
case "$1" in
|
||||
0)
|
||||
cat <<'EOF'
|
||||
,a8888a,
|
||||
,8P"' `"Y8,
|
||||
,8P Y8,
|
||||
88 88
|
||||
88 88
|
||||
`8b d8'
|
||||
`8ba, ,ad8'
|
||||
"Y8888P"
|
||||
EOF
|
||||
;;
|
||||
1)
|
||||
cat <<'EOF'
|
||||
88
|
||||
,d88
|
||||
888888
|
||||
88
|
||||
88
|
||||
88
|
||||
88
|
||||
88
|
||||
EOF
|
||||
;;
|
||||
2)
|
||||
cat <<'EOF'
|
||||
ad888888b,
|
||||
d8" "88
|
||||
a8P
|
||||
,d8P"
|
||||
a8P"
|
||||
a8P'
|
||||
d8"
|
||||
88888888888
|
||||
EOF
|
||||
;;
|
||||
3)
|
||||
cat <<'EOF'
|
||||
ad888888b,
|
||||
d8" "88
|
||||
a8P
|
||||
aad8"
|
||||
""Y8,
|
||||
"8b
|
||||
Y8, a88
|
||||
"Y888888P'
|
||||
EOF
|
||||
;;
|
||||
4)
|
||||
cat <<'EOF'
|
||||
,d8
|
||||
,d888
|
||||
,d8" 88
|
||||
,d8" 88
|
||||
,d8" 88
|
||||
8888888888888
|
||||
88
|
||||
88
|
||||
EOF
|
||||
;;
|
||||
5)
|
||||
cat <<'EOF'
|
||||
8888888888
|
||||
88
|
||||
88 ____
|
||||
88a8PPPP8b,
|
||||
PP" `8b
|
||||
d8
|
||||
Y8a a8P
|
||||
"Y88888P"
|
||||
EOF
|
||||
;;
|
||||
6)
|
||||
cat <<'EOF'
|
||||
ad8888ba,
|
||||
8P' "Y8
|
||||
d8
|
||||
88,dd888bb,
|
||||
88P' `8b
|
||||
88 d8
|
||||
88a a8P
|
||||
"Y88888P"
|
||||
EOF
|
||||
;;
|
||||
7)
|
||||
cat <<'EOF'
|
||||
888888888888
|
||||
,8P'
|
||||
d8"
|
||||
,8P'
|
||||
d8"
|
||||
,8P'
|
||||
d8"
|
||||
8P'
|
||||
EOF
|
||||
;;
|
||||
8)
|
||||
cat <<'EOF'
|
||||
ad88888ba
|
||||
d8" "8b
|
||||
Y8a a8P
|
||||
"Y8aaa8P"
|
||||
,d8"""8b,
|
||||
d8" "8b
|
||||
Y8a a8P
|
||||
"Y88888P"
|
||||
EOF
|
||||
;;
|
||||
9)
|
||||
cat <<'EOF'
|
||||
ad88888ba
|
||||
d8" "88
|
||||
8P 88
|
||||
Y8, ,d88
|
||||
"PPPPPP"88
|
||||
8P
|
||||
8b, a8P
|
||||
`"Y8888P'
|
||||
EOF
|
||||
;;
|
||||
D)
|
||||
cat <<'EOF'
|
||||
88888888ba
|
||||
88 "8b
|
||||
88 8b
|
||||
88 88
|
||||
88 88
|
||||
88 8P
|
||||
88 a8P
|
||||
88888888P"
|
||||
EOF
|
||||
;;
|
||||
H)
|
||||
cat <<'EOF'
|
||||
88 88
|
||||
88 88
|
||||
88 88
|
||||
8888888888
|
||||
88 88
|
||||
88 88
|
||||
88 88
|
||||
88 88
|
||||
EOF
|
||||
;;
|
||||
M)
|
||||
cat <<'EOF'
|
||||
88b d88
|
||||
888b d888
|
||||
88`8b d8'88
|
||||
88 `8b d8' 88
|
||||
88 `8b d8' 88
|
||||
88 `8b d8' 88
|
||||
88 `888' 88
|
||||
88 `8' 88
|
||||
EOF
|
||||
;;
|
||||
:)
|
||||
printf '%s\n' ' ' '888' '888' ' ' ' ' '888' '888' ' '
|
||||
;;
|
||||
' ')
|
||||
printf '%s\n' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' '
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' '???' '???' '???' '???' '???' '???' '???' '???'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_large_countdown() {
|
||||
local text="$1"
|
||||
local columns="$2"
|
||||
local rendered_banner
|
||||
local character glyph_line glyph_width index row rendered_width=0 block_padding=0
|
||||
local -a glyph_lines rows=('' '' '' '' '' '' '' '')
|
||||
|
||||
if command -v pyfiglet >/dev/null 2>&1; then
|
||||
if rendered_banner="$(pyfiglet -f univers -w "$columns" -j center "$text" 2>/dev/null)"; then
|
||||
printf '%s\n' "$rendered_banner"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
if command -v figlet >/dev/null 2>&1; then
|
||||
if rendered_banner="$(figlet -f univers -w "$columns" -c "$text" 2>/dev/null)"; then
|
||||
printf '%s\n' "$rendered_banner"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
for ((index = 0; index < ${#text}; index++)); do
|
||||
character="${text:index:1}"
|
||||
mapfile -t glyph_lines < <(large_glyph "$character")
|
||||
glyph_width=0
|
||||
for row in {0..7}; do
|
||||
if ((${#glyph_lines[row]} > glyph_width)); then
|
||||
glyph_width="${#glyph_lines[row]}"
|
||||
fi
|
||||
done
|
||||
for row in {0..7}; do
|
||||
printf -v glyph_line '%-*s' "$glyph_width" "${glyph_lines[row]:-}"
|
||||
rows[row]+="$glyph_line "
|
||||
done
|
||||
done
|
||||
|
||||
for row in {0..7}; do
|
||||
while [[ "${rows[row]}" == *' ' ]]; do
|
||||
rows[row]="${rows[row]% }"
|
||||
done
|
||||
if ((${#rows[row]} > rendered_width)); then
|
||||
rendered_width="${#rows[row]}"
|
||||
fi
|
||||
done
|
||||
|
||||
if ((rendered_width > columns)); then
|
||||
center_line "$text" "$columns"
|
||||
return
|
||||
fi
|
||||
|
||||
block_padding=$(((columns - rendered_width) / 2))
|
||||
for row in {0..7}; do
|
||||
printf '%*s%s\n' "$block_padding" '' "${rows[row]}"
|
||||
done
|
||||
}
|
||||
|
||||
duration_parts() {
|
||||
local seconds="$1"
|
||||
local minutes days hours
|
||||
|
||||
((seconds < 0)) && seconds=0
|
||||
minutes=$(((seconds + 59) / 60))
|
||||
days=$((minutes / 1440))
|
||||
hours=$(((minutes % 1440) / 60))
|
||||
minutes=$((minutes % 60))
|
||||
printf '%s %s %s\n' "$days" "$hours" "$minutes"
|
||||
}
|
||||
|
||||
banner_duration() {
|
||||
local seconds="$1"
|
||||
local minutes hours
|
||||
|
||||
((seconds < 0)) && seconds=0
|
||||
hours=$((seconds / 3600))
|
||||
minutes=$(((seconds % 3600) / 60))
|
||||
seconds=$((seconds % 60))
|
||||
printf '%02d : %02d : %02d\n' "$hours" "$minutes" "$seconds"
|
||||
}
|
||||
|
||||
human_duration() {
|
||||
local seconds="$1"
|
||||
local days hours minutes output=""
|
||||
|
||||
read -r days hours minutes < <(duration_parts "$seconds")
|
||||
((days)) && output+="${days}d "
|
||||
((hours)) && output+="${hours}h "
|
||||
output+="${minutes}m"
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
render_event() {
|
||||
local line="$1"
|
||||
local now_epoch="$2"
|
||||
local ignored phase start_epoch stop_epoch record type_label timing
|
||||
local -a fields
|
||||
|
||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
||||
record_fields "$record" fields
|
||||
type_label="EMERGENCY"
|
||||
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
|
||||
|
||||
if [[ "$phase" == "ONGOING" ]]; then
|
||||
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
|
||||
else
|
||||
timing="starts in $(human_duration "$((start_epoch - now_epoch))")"
|
||||
fi
|
||||
|
||||
printf '%s%s%-10s%s %s%-9s%s %s\n' \
|
||||
"$BOLD" "$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
|
||||
"$phase" "$RESET" "$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
|
||||
"$type_label" "$RESET" "$timing"
|
||||
printf ' Date %s %s - %s\n' "${fields[3]}" "${fields[4]}" "${fields[5]}"
|
||||
printf ' Reference bill %s / outage %s\n' "${fields[0]}" "${fields[1]}"
|
||||
}
|
||||
|
||||
render_empty() {
|
||||
local columns="$1"
|
||||
local width=$((columns < 64 ? columns : 64))
|
||||
local rule
|
||||
|
||||
rule="$(repeat_character '=' "$width")"
|
||||
printf '%s%s%s\n' "$GREEN" "$rule" "$RESET"
|
||||
printf '\n'
|
||||
center_line "ALL CLEAR" "$width"
|
||||
center_line "No current or upcoming outages." "$width"
|
||||
printf '\n'
|
||||
printf '%s%s%s\n' "$GREEN" "$rule" "$RESET"
|
||||
}
|
||||
|
||||
render_unavailable() {
|
||||
local columns width rule
|
||||
|
||||
color_setup
|
||||
columns="$(terminal_columns)"
|
||||
width=$((columns < 64 ? columns : 64))
|
||||
rule="$(repeat_character '=' "$width")"
|
||||
printf '%s%s%s\n\n' "$RED" "$rule" "$RESET"
|
||||
center_line "STATE UNAVAILABLE" "$width"
|
||||
center_line "Waiting for the next refresh." "$width"
|
||||
printf '\n%s%s%s\n' "$RED" "$rule" "$RESET"
|
||||
}
|
||||
|
||||
start_terminal() {
|
||||
if [[ -t 1 ]]; then
|
||||
TERMINAL_ACTIVE=1
|
||||
printf '\033[?1049h\033[?25l\033[H'
|
||||
fi
|
||||
}
|
||||
|
||||
draw_frame() {
|
||||
local frame="$1"
|
||||
local cleared_frame
|
||||
|
||||
if ((TERMINAL_ACTIVE)); then
|
||||
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
|
||||
printf '\033[H%s\033[K\n\033[J' "$cleared_frame"
|
||||
else
|
||||
printf '%s\n' "$frame"
|
||||
fi
|
||||
}
|
||||
|
||||
schedule_next_transition() {
|
||||
local now_epoch="$1"
|
||||
local line ignored phase start_epoch stop_epoch record candidate=0
|
||||
|
||||
for line in "${SCHEDULE[@]}"; do
|
||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
||||
if ((start_epoch > now_epoch)) && ((candidate == 0 || start_epoch < candidate)); then
|
||||
candidate="$start_epoch"
|
||||
fi
|
||||
if ((stop_epoch > now_epoch)) && ((candidate == 0 || stop_epoch < candidate)); then
|
||||
candidate="$stop_epoch"
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
render_dashboard() {
|
||||
local now_epoch="$1"
|
||||
local columns width rule phase start_epoch stop_epoch record remaining banner
|
||||
local schedule_line ignored
|
||||
|
||||
color_setup
|
||||
columns="$(terminal_columns)"
|
||||
width=$((columns < 150 ? columns : 150))
|
||||
|
||||
if ((${#SCHEDULE[@]} == 0)); then
|
||||
render_empty "$columns"
|
||||
return
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO"
|
||||
if [[ "$phase" == "ONGOING" ]]; then
|
||||
remaining=$((stop_epoch - now_epoch))
|
||||
else
|
||||
remaining=$((start_epoch - now_epoch))
|
||||
fi
|
||||
banner="$(banner_duration "$remaining")"
|
||||
rule="$(repeat_character '=' "$width")"
|
||||
|
||||
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
printf '\n'
|
||||
printf '%s%s' "$BOLD" "$YELLOW"
|
||||
print_large_countdown "$banner" "$width"
|
||||
printf '%s' "$RESET"
|
||||
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
|
||||
|
||||
for schedule_line in "${SCHEDULE[@]}"; do
|
||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line"
|
||||
render_event "$schedule_line" "$now_epoch"
|
||||
printf '%s\n' "$DIM$(repeat_character '-' "$width")$RESET"
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0
|
||||
local last_rendered=-1 next_transition=0 rebuild_schedule=0 frame key
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
-i|--interval)
|
||||
if (($# < 2)); then
|
||||
printf 'Error: %s requires a value\n' "$1" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
fi
|
||||
refresh_minutes="$2"
|
||||
shift 2
|
||||
;;
|
||||
--interval=*)
|
||||
refresh_minutes="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
printf 'Error: unknown option: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [[ ! "$refresh_minutes" =~ ^[0-9]+$ ]] \
|
||||
|| ((10#$refresh_minutes < 1 || 10#$refresh_minutes > 1440)); then
|
||||
printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2
|
||||
return 2
|
||||
fi
|
||||
refresh_seconds=$((10#$refresh_minutes * 60))
|
||||
|
||||
require_command curl
|
||||
require_command jq
|
||||
require_command date
|
||||
|
||||
STATE_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-state.XXXXXX")"
|
||||
DOWNLOAD_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-download.XXXXXX")"
|
||||
REFRESH_STATUS_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-refresh.XXXXXX")"
|
||||
start_terminal
|
||||
|
||||
while true; do
|
||||
printf -v now_epoch '%(%s)T' -1
|
||||
if ((now_epoch == last_rendered)); then
|
||||
if [[ -t 0 ]]; then
|
||||
key=""
|
||||
if read -rsn 1 -t 0.1 key && [[ "$key" == "q" || "$key" == "Q" ]]; then
|
||||
break
|
||||
fi
|
||||
else
|
||||
sleep 0.1
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
last_rendered="$now_epoch"
|
||||
|
||||
if ((now_epoch >= next_refresh)) && [[ -z "$REFRESH_PID" ]]; then
|
||||
start_state_refresh "$now_epoch"
|
||||
next_refresh=$((now_epoch + refresh_seconds))
|
||||
fi
|
||||
if [[ -n "$REFRESH_PID" && -s "$REFRESH_STATUS_PATH" ]]; then
|
||||
if finish_state_refresh; then
|
||||
state_ready=1
|
||||
rebuild_schedule=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((state_ready)); then
|
||||
if ((rebuild_schedule || (next_transition > 0 && now_epoch >= next_transition))); then
|
||||
if build_schedule "$STATE_PATH" "$now_epoch"; then
|
||||
next_transition="$(schedule_next_transition "$now_epoch")"
|
||||
rebuild_schedule=0
|
||||
else
|
||||
state_ready=0
|
||||
REFRESH_ERROR="The cached state contains an invalid date or time."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
frame="$(
|
||||
if ((state_ready)); then
|
||||
render_dashboard "$now_epoch"
|
||||
else
|
||||
render_unavailable
|
||||
fi
|
||||
if [[ -n "$REFRESH_ERROR" ]]; then
|
||||
printf '\n%s\n' "$REFRESH_ERROR"
|
||||
fi
|
||||
if [[ -t 0 ]]; then
|
||||
printf '\nPress q to quit.\n'
|
||||
fi
|
||||
)"
|
||||
draw_frame "$frame"
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
200
tests/test_main.sh
Executable file
200
tests/test_main.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=../main.sh
|
||||
source "$ROOT_DIR/main.sh"
|
||||
|
||||
TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/blackout-cli-tests.XXXXXX")"
|
||||
trap 'rm -rf -- "$TEST_TMP"' EXIT
|
||||
|
||||
assert_equal() {
|
||||
local expected="$1"
|
||||
local actual="$2"
|
||||
local message="$3"
|
||||
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
printf 'FAIL: %s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local text="$1"
|
||||
local expected="$2"
|
||||
local message="$3"
|
||||
|
||||
if [[ "$text" != *"$expected"* ]]; then
|
||||
printf 'FAIL: %s\nMissing: %s\n' "$message" "$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
FIXTURE="$TEST_TMP/outages.json"
|
||||
cat >"$FIXTURE" <<'JSON'
|
||||
{
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-07-18T16:00:00+03:30",
|
||||
"bills": {
|
||||
"222": {
|
||||
"events": {
|
||||
"5": {
|
||||
"status": "active",
|
||||
"snapshot": {
|
||||
"outage_number": "5",
|
||||
"outage_date": "1405/04/28",
|
||||
"start_time": "09:00",
|
||||
"stop_time": "11:00",
|
||||
"address": "Future planned address",
|
||||
"reason": "Maintenance",
|
||||
"is_planned": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"111": {
|
||||
"events": {
|
||||
"1": {
|
||||
"status": "active",
|
||||
"snapshot": {
|
||||
"outage_number": "1",
|
||||
"outage_date": "1405/04/27",
|
||||
"start_time": "16:00",
|
||||
"stop_time": "18:00",
|
||||
"address": "Current address",
|
||||
"reason": "Load management",
|
||||
"is_planned": true
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"status": "active",
|
||||
"snapshot": {
|
||||
"outage_number": "2",
|
||||
"outage_date": "1405/04/27",
|
||||
"start_time": "18:00",
|
||||
"stop_time": "20:00",
|
||||
"address": "Emergency address",
|
||||
"reason": "Emergency load management",
|
||||
"is_planned": false
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"status": "cancelled",
|
||||
"snapshot": {
|
||||
"outage_number": "3",
|
||||
"outage_date": "1405/04/27",
|
||||
"start_time": "19:00",
|
||||
"stop_time": "21:00",
|
||||
"address": "Cancelled address",
|
||||
"reason": "Cancelled maintenance",
|
||||
"is_planned": true
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"status": "active",
|
||||
"snapshot": {
|
||||
"outage_number": "4",
|
||||
"outage_date": "1405/04/27",
|
||||
"start_time": "10:00",
|
||||
"stop_time": "11:00",
|
||||
"address": "Expired address",
|
||||
"reason": "Old event",
|
||||
"is_planned": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
assert_equal "2026-07-18" "$(jalali_to_gregorian "1405/04/27")" \
|
||||
"Jalali dates should convert to Gregorian dates"
|
||||
|
||||
read -r cross_start cross_stop < <(event_epochs "1405/04/27" "23:00" "01:00")
|
||||
assert_equal "7200" "$((cross_stop - cross_start))" \
|
||||
"cross-midnight outages should end on the following day"
|
||||
|
||||
assert_equal "01 : 01 : 01" "$(banner_duration 3661)" \
|
||||
"countdowns should include hours, minutes, and seconds"
|
||||
assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \
|
||||
"countdowns should use total hours beyond one day"
|
||||
FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)"
|
||||
assert_contains "$FONT_OUTPUT" ',a8888a,' \
|
||||
"the countdown should consistently use the univers-style font"
|
||||
|
||||
if ! validate_state "$FIXTURE"; then
|
||||
printf 'FAIL: valid fixture was rejected\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NOW_EPOCH="$(TZ=Asia/Tehran date -d '2026-07-18 17:00:00' +%s)"
|
||||
build_schedule "$FIXTURE" "$NOW_EPOCH"
|
||||
assert_equal "3" "${#SCHEDULE[@]}" \
|
||||
"only active current and future events should be scheduled"
|
||||
assert_equal "ONGOING" "${HERO%%$'\t'*}" \
|
||||
"an ongoing outage should take the hero position"
|
||||
assert_contains "${SCHEDULE[0]}" '"outage_number":"1"' \
|
||||
"ongoing events should sort first"
|
||||
assert_contains "${SCHEDULE[1]}" '"outage_number":"2"' \
|
||||
"the nearest future event should sort second"
|
||||
assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \
|
||||
"later events should remain in the schedule"
|
||||
|
||||
OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")"
|
||||
if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then
|
||||
printf 'FAIL: countdown output contains a redundant heading\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_contains "$OUTPUT" "PLANNED" \
|
||||
"planned events should be labeled"
|
||||
assert_contains "$OUTPUT" "EMERGENCY" \
|
||||
"unplanned events should be labeled"
|
||||
assert_contains "$OUTPUT" "OUTAGE SCHEDULE (3)" \
|
||||
"the dashboard should include every selected event"
|
||||
if [[ "$OUTPUT" == *"Address"* || "$OUTPUT" == *"Reason"* \
|
||||
|| "$OUTPUT" == *"Emergency address"* || "$OUTPUT" == *"Load management"* ]]; then
|
||||
printf 'FAIL: dashboard output exposes address or reason text\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$OUTPUT" == *$'\033['* ]]; then
|
||||
printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)"
|
||||
build_schedule "$FIXTURE" "$FUTURE_NOW"
|
||||
assert_equal "UPCOMING" "${HERO%%$'\t'*}" \
|
||||
"the nearest future event should be the hero when none are ongoing"
|
||||
|
||||
EMPTY_NOW="$(TZ=Asia/Tehran date -d '2026-07-20 12:00:00' +%s)"
|
||||
build_schedule "$FIXTURE" "$EMPTY_NOW"
|
||||
assert_equal "0" "${#SCHEDULE[@]}" \
|
||||
"expired schedules should become empty"
|
||||
EMPTY_OUTPUT="$(NO_COLOR=1 COLUMNS=60 render_dashboard "$EMPTY_NOW")"
|
||||
assert_contains "$EMPTY_OUTPUT" "ALL CLEAR" \
|
||||
"an empty schedule should render the all-clear screen"
|
||||
|
||||
TERMINAL_ACTIVE=1
|
||||
DRAW_OUTPUT="$(draw_frame $'short\nlonger')"
|
||||
TERMINAL_ACTIVE=0
|
||||
assert_contains "$DRAW_OUTPUT" $'short\033[K\nlonger\033[K' \
|
||||
"interactive redraws should erase old content from every line"
|
||||
|
||||
MALFORMED="$TEST_TMP/malformed.json"
|
||||
printf '{"schema_version": 999, "bills": {}}\n' >"$MALFORMED"
|
||||
if validate_state "$MALFORMED"; then
|
||||
printf 'FAIL: unsupported schema was accepted\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (
|
||||
curl() { return 22; }
|
||||
fetch_state "$TEST_TMP/download.json" "123"
|
||||
); then
|
||||
printf 'FAIL: download failure was reported as success\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Bash CLI tests passed\n'
|
||||
Reference in New Issue
Block a user