feat: add live outage countdown cli dashboard
Some checks failed
Test blackout notifier / test (push) Failing after 2s
Hourly blackout check / check (push) Successful in 3s

This commit is contained in:
2026-07-18 08:11:55 +03:30
parent 0abf377f53
commit ded914a6fd
4 changed files with 1140 additions and 0 deletions

895
main.sh Executable file
View 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