Files
linux-scripts/hetzner-cost-monitor.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

627 lines
26 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#########################################################################################
#### hetzner-cost-monitor.sh — Track and report Hetzner Cloud spending via the REST ####
#### API. Server, volume, snapshot, and load balancer costs with alert thresholds ####
#### Requires: bash 4+, curl, jq ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.01 ####
#### ####
#### Usage: ####
#### ./hetzner-cost-monitor.sh --summary ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Colors (pre-initialized) ─────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
setup_colors() {
if [[ "${COLOR:-auto}" == "never" ]]; then
return
fi
if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
fi
}
# ── Logging ───────────────────────────────────────────────────────────
log() { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; }
die() { err "$*"; exit 1; }
section_header() {
echo ""
echo -e " ${BOLD}${CYAN}── $1 ──${RESET}"
echo ""
}
field() {
printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2"
}
field_color() {
printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2"
}
elapsed() {
local end_time
end_time=$(date +%s)
echo "$(( end_time - START_TIME ))s"
}
# ── Defaults ──────────────────────────────────────────────────────────
RUN_MODE=""
OUTPUT_FORMAT="${HCM_FORMAT:-table}"
ALERT_THRESHOLD="${HCM_ALERT:-0}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── Credentials ───────────────────────────────────────────────────────
HCLOUD_TOKEN="${HCLOUD_TOKEN:-}"
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
START_TIME=""
# ── API helpers ──────────────────────────────────────────────────────
hcloud_api() {
local method="$1" endpoint="$2"
shift 2
local attempt=0 max_attempts=3
while (( attempt < max_attempts )); do
local http_code
http_code=$(curl -s -o /tmp/hcm_resp.json -w "%{http_code}" \
-X "$method" \
-H "Authorization: Bearer ${HCLOUD_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.hetzner.cloud/v1${endpoint}" "$@")
verbose "API ${method} ${endpoint} → HTTP ${http_code}"
if [[ "$http_code" == "429" ]]; then
((attempt++)) || true
local wait=$(( attempt * 5 ))
warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})"
sleep "$wait"
continue
fi
if [[ "$http_code" =~ ^[45] ]]; then
local errmsg
errmsg=$(jq -r '.error.message // empty' /tmp/hcm_resp.json 2>/dev/null)
[[ -n "$errmsg" ]] && verbose "API error: ${errmsg}"
fi
cat /tmp/hcm_resp.json
return 0
done
err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}"
return 1
}
check_credentials() {
[[ -z "$HCLOUD_TOKEN" ]] && die "HCLOUD_TOKEN not set"
}
check_deps() {
command -v curl &>/dev/null || die "curl is required"
command -v jq &>/dev/null || die "jq is required"
}
# ── Pagination helper ────────────────────────────────────────────────
fetch_all() {
local endpoint="$1" key="$2"
local page=1 per_page=50 all_data="[]"
while true; do
local sep="?"
[[ "$endpoint" == *"?"* ]] && sep="&"
local resp
resp=$(hcloud_api GET "${endpoint}${sep}page=${page}&per_page=${per_page}")
local page_data
page_data=$(echo "$resp" | jq ".${key} // []" 2>/dev/null)
local page_count
page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0)
[[ "$page_count" -eq 0 ]] && break
all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null)
(( page_count < per_page )) && break
((page++)) || true
done
echo "$all_data"
}
# ── Cost calculation helpers ─────────────────────────────────────────
get_pricing() {
hcloud_api GET "/pricing"
}
calc_server_costs() {
local servers="$1" pricing="$2"
local total="0"
echo "$servers" | jq -r '.[] | "\(.server_type.name)\t\(.status)"' 2>/dev/null \
| while IFS=$'\t' read -r stype status; do
if [[ "$status" == "running" ]]; then
local hourly
hourly=$(echo "$pricing" | jq -r \
--arg t "$stype" \
'.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross' \
2>/dev/null)
[[ -n "$hourly" && "$hourly" != "null" ]] && echo "$hourly"
fi
done \
| awk '{s+=$1} END {printf "%.2f", s * 730}'
}
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════
do_summary() {
local pricing
pricing=$(get_pricing)
local servers
servers=$(fetch_all "/servers" "servers")
local server_count
server_count=$(echo "$servers" | jq 'length' 2>/dev/null || echo 0)
local running_count
running_count=$(echo "$servers" | jq '[.[] | select(.status == "running")] | length' 2>/dev/null || echo 0)
local volumes
volumes=$(fetch_all "/volumes" "volumes")
local volume_count
volume_count=$(echo "$volumes" | jq 'length' 2>/dev/null || echo 0)
local volume_gb
volume_gb=$(echo "$volumes" | jq '[.[].size] | add // 0' 2>/dev/null || echo 0)
local images
images=$(fetch_all "/images?type=snapshot" "images")
local snapshot_count
snapshot_count=$(echo "$images" | jq 'length' 2>/dev/null || echo 0)
local snapshot_gb
snapshot_gb=$(echo "$images" | jq '[.[].image_size // 0] | add // 0' 2>/dev/null || echo 0)
local lbs
lbs=$(fetch_all "/load_balancers" "load_balancers")
local lb_count
lb_count=$(echo "$lbs" | jq 'length' 2>/dev/null || echo 0)
local ips
ips=$(fetch_all "/floating_ips" "floating_ips")
local ip_count
ip_count=$(echo "$ips" | jq 'length' 2>/dev/null || echo 0)
# Monthly cost estimates from pricing API
local volume_price_gb
volume_price_gb=$(echo "$pricing" | jq -r '.pricing.volume.price_per_gb_per_month.gross // "0"' 2>/dev/null)
local volume_cost
volume_cost=$(awk "BEGIN {printf \"%.2f\", ${volume_gb} * ${volume_price_gb}}")
local snapshot_price_gb
snapshot_price_gb=$(echo "$pricing" | jq -r '.pricing.image.price_per_gb_month.gross // "0"' 2>/dev/null)
local snapshot_cost
snapshot_cost=$(awk "BEGIN {printf \"%.2f\", ${snapshot_gb} * ${snapshot_price_gb}}")
local ip_price
ip_price=$(echo "$pricing" | jq -r '.pricing.floating_ip.price_monthly.gross // "0"' 2>/dev/null)
local ip_cost
ip_cost=$(awk "BEGIN {printf \"%.2f\", ${ip_count} * ${ip_price}}")
# Server costs — sum hourly × 730 hours/month
local server_cost="0.00"
while IFS=$'\t' read -r stype status; do
[[ -z "$stype" ]] && continue
if [[ "$status" == "running" ]]; then
local hourly
hourly=$(echo "$pricing" | jq -r \
--arg t "$stype" \
'[.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \
2>/dev/null)
server_cost=$(awk "BEGIN {printf \"%.2f\", ${server_cost} + (${hourly} * 730)}")
fi
done < <(echo "$servers" | jq -r '.[] | "\(.server_type.name)\t\(.status)"' 2>/dev/null)
# LB costs
local lb_cost="0.00"
while IFS= read -r lbtype; do
[[ -z "$lbtype" ]] && continue
local lb_hourly
lb_hourly=$(echo "$pricing" | jq -r \
--arg t "$lbtype" \
'[.pricing.load_balancer_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \
2>/dev/null)
lb_cost=$(awk "BEGIN {printf \"%.2f\", ${lb_cost} + (${lb_hourly} * 730)}")
done < <(echo "$lbs" | jq -r '.[].load_balancer_type.name // empty' 2>/dev/null)
local total_cost
total_cost=$(awk "BEGIN {printf \"%.2f\", ${server_cost} + ${volume_cost} + ${snapshot_cost} + ${lb_cost} + ${ip_cost}}")
# Alert check
local alert_triggered="false"
if [[ "$ALERT_THRESHOLD" != "0" ]]; then
local over
over=$(awk "BEGIN {print (${total_cost} > ${ALERT_THRESHOLD}) ? 1 : 0}")
[[ "$over" == "1" ]] && alert_triggered="true"
fi
case "$OUTPUT_FORMAT" in
json)
jq -n \
--argjson servers "$server_count" \
--argjson running "$running_count" \
--argjson volumes "$volume_count" \
--argjson volume_gb "$volume_gb" \
--argjson snapshots "$snapshot_count" \
--arg snapshot_gb "$snapshot_gb" \
--argjson load_balancers "$lb_count" \
--argjson floating_ips "$ip_count" \
--arg server_cost "$server_cost" \
--arg volume_cost "$volume_cost" \
--arg snapshot_cost "$snapshot_cost" \
--arg lb_cost "$lb_cost" \
--arg ip_cost "$ip_cost" \
--arg total_cost "$total_cost" \
--arg alert_threshold "$ALERT_THRESHOLD" \
--argjson alert_triggered "$alert_triggered" \
'{
servers: $servers, running: $running,
volumes: $volumes, volume_gb: $volume_gb,
snapshots: $snapshots, snapshot_gb: ($snapshot_gb | tonumber),
load_balancers: $load_balancers, floating_ips: $floating_ips,
monthly_estimate: {
servers: $server_cost, volumes: $volume_cost,
snapshots: $snapshot_cost, load_balancers: $lb_cost,
floating_ips: $ip_cost, total: $total_cost
},
alert: { threshold: $alert_threshold, triggered: $alert_triggered }
}'
;;
prometheus)
cat <<EOF
# HELP hetzner_cost_monthly_estimate_euros Estimated monthly cost in euros
# TYPE hetzner_cost_monthly_estimate_euros gauge
hetzner_cost_monthly_estimate_euros ${total_cost}
# HELP hetzner_cost_servers_euros Estimated monthly server cost
# TYPE hetzner_cost_servers_euros gauge
hetzner_cost_servers_euros ${server_cost}
# HELP hetzner_cost_volumes_euros Estimated monthly volume cost
# TYPE hetzner_cost_volumes_euros gauge
hetzner_cost_volumes_euros ${volume_cost}
# HELP hetzner_cost_snapshots_euros Estimated monthly snapshot cost
# TYPE hetzner_cost_snapshots_euros gauge
hetzner_cost_snapshots_euros ${snapshot_cost}
# HELP hetzner_cost_load_balancers_euros Estimated monthly load balancer cost
# TYPE hetzner_cost_load_balancers_euros gauge
hetzner_cost_load_balancers_euros ${lb_cost}
# HELP hetzner_cost_floating_ips_euros Estimated monthly floating IP cost
# TYPE hetzner_cost_floating_ips_euros gauge
hetzner_cost_floating_ips_euros ${ip_cost}
# HELP hetzner_cost_servers_total Total servers
# TYPE hetzner_cost_servers_total gauge
hetzner_cost_servers_total ${server_count}
# HELP hetzner_cost_servers_running Running servers
# TYPE hetzner_cost_servers_running gauge
hetzner_cost_servers_running ${running_count}
# HELP hetzner_cost_volumes_total Total volumes
# TYPE hetzner_cost_volumes_total gauge
hetzner_cost_volumes_total ${volume_count}
# HELP hetzner_cost_volume_gb_total Total volume storage in GB
# TYPE hetzner_cost_volume_gb_total gauge
hetzner_cost_volume_gb_total ${volume_gb}
# HELP hetzner_cost_snapshots_total Total snapshots
# TYPE hetzner_cost_snapshots_total gauge
hetzner_cost_snapshots_total ${snapshot_count}
# HELP hetzner_cost_load_balancers_total Total load balancers
# TYPE hetzner_cost_load_balancers_total gauge
hetzner_cost_load_balancers_total ${lb_count}
# HELP hetzner_cost_floating_ips_total Total floating IPs
# TYPE hetzner_cost_floating_ips_total gauge
hetzner_cost_floating_ips_total ${ip_count}
# HELP hetzner_cost_alert_triggered Whether cost alert threshold exceeded
# TYPE hetzner_cost_alert_triggered gauge
hetzner_cost_alert_triggered $([ "$alert_triggered" == "true" ] && echo 1 || echo 0)
EOF
;;
*)
section_header "Hetzner Cloud Cost Summary"
printf " ${BOLD}%-20s %8s %10s %12s${RESET}\n" \
"RESOURCE" "COUNT" "SIZE" "MONTHLY €"
printf " %s\n" "$(printf '%.0s─' {1..54})"
printf " %-20s %8s %10s %12s\n" \
"Servers (running)" "$running_count" "—" "${server_cost}"
printf " %-20s %8s %10s %12s\n" \
"Servers (total)" "$server_count" "—" "—"
printf " %-20s %8s %10s %12s\n" \
"Volumes" "$volume_count" "${volume_gb} GB" "${volume_cost}"
printf " %-20s %8s %10s %12s\n" \
"Snapshots" "$snapshot_count" "${snapshot_gb} GB" "${snapshot_cost}"
printf " %-20s %8s %10s %12s\n" \
"Load Balancers" "$lb_count" "—" "${lb_cost}"
printf " %-20s %8s %10s %12s\n" \
"Floating IPs" "$ip_count" "—" "${ip_cost}"
printf " %s\n" "$(printf '%.0s─' {1..54})"
printf " ${BOLD}%-20s %8s %10s %12s${RESET}\n" \
"TOTAL" "" "" "${total_cost}"
if [[ "$alert_triggered" == "true" ]]; then
echo ""
echo -e " ${RED}⚠ ALERT: Monthly estimate €${total_cost} exceeds threshold €${ALERT_THRESHOLD}${RESET}"
fi
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# BREAKDOWN
# ══════════════════════════════════════════════════════════════════════
do_breakdown() {
local pricing
pricing=$(get_pricing)
local servers
servers=$(fetch_all "/servers" "servers")
local server_count
server_count=$(echo "$servers" | jq 'length' 2>/dev/null || echo 0)
[[ "$server_count" -eq 0 ]] && die "No servers found"
case "$OUTPUT_FORMAT" in
json)
echo "$servers" | jq '[.[] | {
id: .id, name: .name, status: .status,
type: .server_type.name, location: .datacenter.location.name,
ip: .public_net.ipv4.ip
}]'
;;
*)
section_header "Server Cost Breakdown"
printf " ${BOLD}%-10s %-20s %-10s %-8s %-8s %10s${RESET}\n" \
"ID" "NAME" "TYPE" "STATUS" "LOC" "MONTHLY €"
printf " %s\n" "$(printf '%.0s─' {1..70})"
while IFS=$'\t' read -r sid sname stype status loc; do
[[ -z "$sid" ]] && continue
local hourly="0"
if [[ "$status" == "running" ]]; then
hourly=$(echo "$pricing" | jq -r \
--arg t "$stype" \
'[.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \
2>/dev/null)
fi
local monthly
monthly=$(awk "BEGIN {printf \"%.2f\", ${hourly} * 730}")
local status_color="$GREEN"
[[ "$status" != "running" ]] && status_color="$DIM"
printf " %-10s %-20s %-10s " "$sid" "${sname:0:18}" "$stype"
echo -ne "${status_color}"
printf "%-8s" "$status"
echo -ne "${RESET}"
printf " %-8s %10s\n" "${loc:0:6}" "$monthly"
done < <(echo "$servers" | jq -r \
'.[] | "\(.id)\t\(.name // "unknown")\t\(.server_type.name // "—")\t\(.status)\t\(.datacenter.location.name // "—")"' \
2>/dev/null)
echo ""
field "Servers:" "$server_count"
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# RESOURCES
# ══════════════════════════════════════════════════════════════════════
do_resources() {
case "$OUTPUT_FORMAT" in
json)
local volumes snapshots lbs ips
volumes=$(fetch_all "/volumes" "volumes")
snapshots=$(fetch_all "/images?type=snapshot" "images")
lbs=$(fetch_all "/load_balancers" "load_balancers")
ips=$(fetch_all "/floating_ips" "floating_ips")
jq -n \
--argjson volumes "$volumes" \
--argjson snapshots "$snapshots" \
--argjson load_balancers "$lbs" \
--argjson floating_ips "$ips" \
'{volumes: $volumes, snapshots: $snapshots, load_balancers: $load_balancers, floating_ips: $floating_ips}'
;;
*)
# Volumes
local volumes
volumes=$(fetch_all "/volumes" "volumes")
local vol_count
vol_count=$(echo "$volumes" | jq 'length' 2>/dev/null || echo 0)
if [[ "$vol_count" -gt 0 ]]; then
section_header "Volumes"
printf " ${BOLD}%-12s %-20s %-8s %-10s %-8s${RESET}\n" \
"ID" "NAME" "SIZE" "LOCATION" "STATUS"
printf " %s\n" "$(printf '%.0s─' {1..60})"
echo "$volumes" | jq -r \
'.[] | "\(.id)\t\(.name // "—")\t\(.size)GB\t\(.location.name // "—")\t\(.status // "—")"' \
2>/dev/null \
| while IFS=$'\t' read -r vid vname vsize vloc vstatus; do
printf " %-12s %-20s %-8s %-10s %-8s\n" \
"$vid" "${vname:0:18}" "$vsize" "${vloc:0:8}" "$vstatus"
done
echo ""
field "Volumes:" "$vol_count"
fi
# Snapshots
local snapshots
snapshots=$(fetch_all "/images?type=snapshot" "images")
local snap_count
snap_count=$(echo "$snapshots" | jq 'length' 2>/dev/null || echo 0)
if [[ "$snap_count" -gt 0 ]]; then
section_header "Snapshots"
printf " ${BOLD}%-12s %-22s %-10s %-20s${RESET}\n" \
"ID" "DESCRIPTION" "SIZE" "CREATED"
printf " %s\n" "$(printf '%.0s─' {1..66})"
echo "$snapshots" | jq -r \
'.[] | "\(.id)\t\(.description // "—")\t\(.image_size // 0)GB\t\(.created // "—")"' \
2>/dev/null \
| while IFS=$'\t' read -r iid idesc isize icreated; do
printf " %-12s %-22s %-10s %-20s\n" \
"$iid" "${idesc:0:20}" "$isize" "${icreated:0:19}"
done
echo ""
field "Snapshots:" "$snap_count"
fi
# Floating IPs
local ips
ips=$(fetch_all "/floating_ips" "floating_ips")
local ip_count
ip_count=$(echo "$ips" | jq 'length' 2>/dev/null || echo 0)
if [[ "$ip_count" -gt 0 ]]; then
section_header "Floating IPs"
printf " ${BOLD}%-12s %-18s %-10s %-12s${RESET}\n" \
"ID" "IP" "TYPE" "LOCATION"
printf " %s\n" "$(printf '%.0s─' {1..54})"
echo "$ips" | jq -r \
'.[] | "\(.id)\t\(.ip)\t\(.type)\t\(.home_location.name // "—")"' \
2>/dev/null \
| while IFS=$'\t' read -r fid fip ftype floc; do
printf " %-12s %-18s %-10s %-12s\n" \
"$fid" "$fip" "$ftype" "$floc"
done
echo ""
field "Floating IPs:" "$ip_count"
fi
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# HELP
# ══════════════════════════════════════════════════════════════════════
show_help() {
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} — Hetzner Cloud Cost Monitor
Track and report Hetzner Cloud spending via the REST API.
${BOLD}MODES${RESET}
--summary Cost summary across all resources (default)
--breakdown Per-server cost breakdown
--resources List all billable resources (volumes, snapshots, IPs)
${BOLD}OPTIONS${RESET}
--format FMT Output: table, json, prometheus (default: table)
--alert EUROS Alert if monthly estimate exceeds threshold
--verbose Debug output
--no-color Disable colored output
--help Show this help message
${BOLD}ENVIRONMENT VARIABLES${RESET}
HCLOUD_TOKEN Hetzner Cloud API token (required)
HCM_FORMAT Default output format
HCM_ALERT Default alert threshold in euros
VERBOSE Enable verbose output (true/false)
COLOR Color mode: auto, always, never
${BOLD}EXAMPLES${RESET}
# Cost summary
${SCRIPT_NAME} --summary
# Per-server breakdown
${SCRIPT_NAME} --breakdown
# List all billable resources
${SCRIPT_NAME} --resources
# JSON output
${SCRIPT_NAME} --summary --format json
# Alert if monthly cost exceeds €50
${SCRIPT_NAME} --summary --alert 50
# Prometheus metrics
${SCRIPT_NAME} --summary --format prometheus
# Cron — hourly cost metrics
0 * * * * /usr/local/bin/hetzner-cost-monitor.sh --summary --format prometheus --no-color > /var/lib/node_exporter/textfile/hetzner_cost.prom 2>/dev/null
${BOLD}EXIT CODES${RESET}
0 Success
1 Runtime error
EOF
}
# ══════════════════════════════════════════════════════════════════════
# PARSE ARGS
# ══════════════════════════════════════════════════════════════════════
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--summary) RUN_MODE="summary"; shift ;;
--breakdown) RUN_MODE="breakdown"; shift ;;
--resources) RUN_MODE="resources"; shift ;;
--format) OUTPUT_FORMAT="${2:?--format requires a value}"; shift 2 ;;
--alert) ALERT_THRESHOLD="${2:?--alert requires a threshold}"; shift 2 ;;
--verbose) VERBOSE="true"; shift ;;
--no-color) COLOR="never"; shift ;;
--help|-h) setup_colors; show_help; exit 0 ;;
*) die "Unknown option: $1 (see --help)" ;;
esac
done
}
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
main() {
parse_args "$@"
setup_colors
if [[ -z "$RUN_MODE" ]]; then
RUN_MODE="summary"
fi
check_deps
check_credentials
START_TIME=$(date +%s)
case "$RUN_MODE" in
summary) do_summary ;;
breakdown) do_breakdown ;;
resources) do_resources ;;
*) die "Unknown mode: ${RUN_MODE}" ;;
esac
if [[ "$OUTPUT_FORMAT" != "prometheus" ]]; then
echo ""
field "Duration:" "$(elapsed)"
fi
}
main "$@"