Ctrl K
ComunidadGuías…

setup.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# EasyMonitor — unified installer.
#
# Run this from the project root. It will:
#   1. Ask whether this is a Local (dev) or Production install
#   2. Collect required (and optional) configuration interactively
#   3. Generate or update .env
#   4. Patch docker/caddy/Caddyfile.production for production installs
#   5. Build and start the docker stack
#   6. Run migrations, generate keys, build assets, set up storage
#
# Re-running is safe: it will detect existing config and offer to keep it.
#
# Upgrading an existing install:
#   git pull && ./setup.sh --upgrade
# rebuilds the containers, runs migrations, and re-applies policies without
# asking any configuration questions. See UPGRADING.md.

set -e

# ── colors ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'

info()    { printf "${BLUE}ℹ${NC}  %s\n" "$1"; }
ok()      { printf "${GREEN}✓${NC}  %s\n" "$1"; }
warn()    { printf "${YELLOW}⚠${NC}  %s\n" "$1"; }
fail()    { printf "${RED}✗${NC}  %s\n" "$1"; exit 1; }
header()  { printf "\n${BOLD}━━━ %s ━━━${NC}\n\n" "$1"; }

# ── helpers ─────────────────────────────────────────────────────────────────
require_cmd() {
    command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
}

ask() {
    # ask <prompt> <default>  → echoes the answer
    local prompt="$1"
    local default="$2"
    local answer

    if [ -n "$default" ]; then
        read -p "  $prompt [$default]: " answer
        echo "${answer:-$default}"
    else
        read -p "  $prompt: " answer
        echo "$answer"
    fi
}

ask_secret() {
    # ask_secret <prompt>  → echoes the typed secret (hidden), no default
    local prompt="$1"
    local answer
    read -s -p "  $prompt: " answer
    echo "" >&2
    echo "$answer"
}

confirm() {
    # confirm <prompt>  → returns 0 for y, 1 for n (default n)
    local prompt="$1"
    local answer
    read -p "  $prompt [y/N]: " answer
    [[ "$answer" =~ ^[Yy]$ ]]
}

random_string() {
    # 32 chars, base64-safe-ish
    openssl rand -base64 32 | tr -d '/+=\n' | cut -c1-32
}

detect_public_ip() {
    # Try a few services; first one that responds wins. Empty if all fail.
    # Try a few IPv4-only services; first one that responds wins.
    # -4 forces IPv4 on the local end, and these services return IPv4
    # so we don't accidentally grab an AAAA address on dual-stack hosts.
    for svc in "https://api.ipify.org" "https://ipv4.icanhazip.com" "https://v4.ident.me" "https://ifconfig.me" "https://ifconfig.net"; do
        local ip
        ip=$(curl -4 -fsS --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]')
        if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "$ip"
            return 0
        fi
    done
    echo ""
}

resolve_domain() {
    # Resolve <domain> to an IP. Tries 'dig' then 'host' then 'getent'.
    local domain="$1"
    local ip
    if command -v dig >/dev/null 2>&1; then
        ip=$(dig +short "$domain" A | head -n1)
    elif command -v host >/dev/null 2>&1; then
        ip=$(host -t A "$domain" 2>/dev/null | awk '/has address/ {print $4; exit}')
    elif command -v getent >/dev/null 2>&1; then
        ip=$(getent hosts "$domain" | awk '{print $1; exit}')
    fi
    echo "$ip"
}

set_env() {
    # set_env <key> <value>  — adds or updates a key in .env
    local key="$1"
    local value="$2"
    # escape chars dangerous to sed
    local escaped
    escaped=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g')

    if grep -q "^${key}=" .env 2>/dev/null; then
        sed -i.bak "s/^${key}=.*/${key}=${escaped}/" .env && rm -f .env.bak
    else
        echo "${key}=${value}" >> .env
    fi
}

# ── args ────────────────────────────────────────────────────────────────────
UPGRADE=false
for arg in "$@"; do
    case "$arg" in
        --upgrade) UPGRADE=true ;;
        -h|--help)
            echo "Usage: ./setup.sh            interactive install"
            echo "       ./setup.sh --upgrade  upgrade an existing install (run 'git pull' first)"
            exit 0
            ;;
        *) fail "Unknown option: $arg (try --help)" ;;
    esac
done

# ── checks ──────────────────────────────────────────────────────────────────
require_cmd docker
require_cmd openssl
docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 not available (run 'docker compose version' to check)"

cat <<'BANNER'

╔══════════════════════════════════════════════════════════════╗
║               EasyMonitor — Installer                        ║
╚══════════════════════════════════════════════════════════════╝

BANNER

# ── upgrade mode ────────────────────────────────────────────────────────────
if $UPGRADE; then
    header "Upgrade"

    [ -s .env ] || fail "No .env found — run ./setup.sh (without --upgrade) for a first-time install."

    info "Rebuilding images and restarting containers (this may take a few minutes)..."
    docker compose up -d --build
    ok "Containers up."

    info "Waiting for services to become responsive..."
    sleep 6

    header "Application upgrade"
    docker compose exec -T php bash /var/www/html/docker/scripts/setup.sh

    info "Applying check result retention policy..."
    docker compose exec -T php php artisan easymonitor:retention --no-interaction \
        || warn "Could not apply retention policy — run 'docker compose exec php php artisan easymonitor:retention' manually."

    # The php container is only recreated when its image changes; code is
    # bind-mounted, so long-running workers must be told to reload it.
    info "Restarting queue workers so they pick up the new code..."
    docker compose exec -T php php artisan horizon:terminate >/dev/null 2>&1 \
        || warn "Could not restart Horizon — run 'docker compose exec php php artisan horizon:terminate' manually."

    info "Restarting probe..."
    docker compose up -d --force-recreate probe >/dev/null 2>&1 || true

    header "Done"
    ok "EasyMonitor upgraded."
    info "Check UPGRADING.md and the release notes for anything requiring manual steps."
    exit 0
fi

# ── mode ────────────────────────────────────────────────────────────────────
header "Install mode"
echo "  1) Local development"
echo "  2) Production (auto-HTTPS via Let's Encrypt + on-demand TLS for status pages)"
echo ""
MODE=""
while [[ "$MODE" != "1" && "$MODE" != "2" ]]; do
    read -p "  Choose [1/2]: " MODE
done

IS_PROD=false
[ "$MODE" = "2" ] && IS_PROD=true

# ── .env ────────────────────────────────────────────────────────────────────
if [ -f .env ]; then
    warn ".env already exists."
    if ! confirm "Continue and update existing .env in place?"; then
        info "Aborting. Move .env aside and re-run if you want a clean start."
        exit 0
    fi
else
    cp .env.example .env
    ok "Created .env from .env.example"
fi

# ── basics ──────────────────────────────────────────────────────────────────
header "Basics"

APP_NAME=$(ask "App name" "EasyMonitor")
set_env APP_NAME "$APP_NAME"

if $IS_PROD; then
    info "Detecting this server's public IP address..."
    PUBLIC_IP=$(detect_public_ip)
    if [ -n "$PUBLIC_IP" ]; then
        ok "Server public IP: ${PUBLIC_IP}"
    else
        warn "Could not auto-detect public IP. You'll need to know it for DNS setup."
    fi
    echo ""

    DOMAIN=$(ask "Primary domain (e.g. monitor.example.com)" "")
    [ -z "$DOMAIN" ] && fail "Primary domain is required for production"

    # DNS sanity check — warn early if the domain doesn't already point here.
    DOMAIN_IP=$(resolve_domain "$DOMAIN")
    if [ -n "$DOMAIN_IP" ] && [ -n "$PUBLIC_IP" ]; then
        if [ "$DOMAIN_IP" = "$PUBLIC_IP" ]; then
            ok "DNS check: ${DOMAIN} resolves to ${DOMAIN_IP} (matches this server)."
        else
            warn "DNS mismatch: ${DOMAIN} resolves to ${DOMAIN_IP}, but this server is ${PUBLIC_IP}."
            warn "Update your DNS A record to ${PUBLIC_IP} before HTTPS will work."
            confirm "Continue anyway?" || fail "Aborting. Fix DNS and re-run."
        fi
    elif [ -z "$DOMAIN_IP" ]; then
        warn "${DOMAIN} doesn't resolve to any IP yet."
        warn "Add a DNS A record:  ${DOMAIN}  →  ${PUBLIC_IP:-<this-server-ip>}"
        confirm "Continue anyway? (TLS will fail until DNS propagates)" || fail "Aborting. Fix DNS and re-run."
    fi

    ADMIN_EMAIL=$(ask "Admin email (for Let's Encrypt notifications)" "")
    [ -z "$ADMIN_EMAIL" ] && fail "Admin email is required for production"

    set_env APP_ENV "production"
    set_env APP_DEBUG "false"
    set_env APP_URL "https://${DOMAIN}"
    ok "App URL set to https://${DOMAIN}"
else
    set_env APP_ENV "local"
    set_env APP_DEBUG "true"
    set_env APP_URL "http://localhost"
    ok "App URL set to http://localhost"
fi

# ── database ────────────────────────────────────────────────────────────────
header "Database"

if $IS_PROD; then
    DB_PASSWORD=$(random_string)
    info "Generated a strong random DB password."
else
    DB_PASSWORD=$(ask "Database password" "secret")
fi
set_env DB_PASSWORD "$DB_PASSWORD"

# ── data retention ──────────────────────────────────────────────────────────
header "Data retention"

echo "  Raw check results are pruned automatically to keep disk usage bounded"
echo "  (older data is also compressed). See DOCKER.md for details. You can"
echo "  change this later in .env and re-apply with: php artisan easymonitor:retention"
echo ""
RETENTION_DAYS=""
while [[ ! "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || [ "$RETENTION_DAYS" -lt 8 ]; do
    RETENTION_DAYS=$(ask "Keep raw check results for how many days? (minimum 8)" "90")
done
set_env CHECK_RESULT_RETENTION_DAYS "$RETENTION_DAYS"
ok "Check results will be kept for ${RETENTION_DAYS} days (compressed after 7 days)."

# ── redis ───────────────────────────────────────────────────────────────────
header "Redis"

if $IS_PROD; then
    REDIS_PASSWORD=$(random_string)
    set_env REDIS_PASSWORD "$REDIS_PASSWORD"
    ok "Generated Redis password (required for production)."
else
    info "Leaving Redis without password (local dev)."
    REDIS_PASSWORD=""
fi

# ── remote probes ───────────────────────────────────────────────────────────
REMOTE_PROBES=false
REMOTE_REDIS_URL=""

header "Remote probe nodes"

echo "  EasyMonitor runs a local probe out of the box. If you want probes in"
echo "  other regions, they need a network path to this server's Redis."
echo ""
echo "  Recommended approach: a VPN-style tunnel (Tailscale or Cloudflare"
echo "  Tunnel). Never expose Redis directly on the public internet."
echo ""
if confirm "Will you run probes on other machines?"; then
    REMOTE_PROBES=true

    echo ""
    echo "  1) Tailscale  — simplest. Free for up to 100 devices."
    echo "  2) Cloudflare Tunnel — free with any Cloudflare account, more setup."
    echo "  3) I'll configure networking manually (SSH, WireGuard, etc.)"
    echo ""
    TUNNEL_CHOICE=""
    while [[ ! "$TUNNEL_CHOICE" =~ ^[1-3]$ ]]; do
        read -p "  Choose [1/2/3]: " TUNNEL_CHOICE
    done

    case "$TUNNEL_CHOICE" in
        1)
            # Tailscale
            if command -v tailscale >/dev/null 2>&1; then
                ok "Tailscale already installed."
            else
                info "Tailscale is not installed."
                if confirm "Install Tailscale now? (runs the official installer)"; then
                    curl -fsSL https://tailscale.com/install.sh | sh || fail "Tailscale install failed."
                    ok "Tailscale installed."
                else
                    warn "Skipping Tailscale install. Follow https://tailscale.com/download when ready."
                fi
            fi

            if command -v tailscale >/dev/null 2>&1; then
                if ! tailscale status >/dev/null 2>&1; then
                    info "Starting Tailscale — a browser-based login URL will open."
                    sudo tailscale up || warn "Tailscale up failed. Run 'sudo tailscale up' manually."
                fi

                TAILSCALE_IP=$(tailscale ip -4 2>/dev/null | head -n1 || true)
                if [ -n "$TAILSCALE_IP" ]; then
                    ok "Tailscale IP: $TAILSCALE_IP"
                    set_env REDIS_BIND_HOST "$TAILSCALE_IP"
                    REMOTE_REDIS_URL="redis://${TAILSCALE_IP}:6379/0"
                else
                    warn "Couldn't read Tailscale IP. Run 'tailscale ip -4' after setup and update REDIS_BIND_HOST in .env."
                    set_env REDIS_BIND_HOST "127.0.0.1"
                    REMOTE_REDIS_URL="redis://<tailscale-ip>:6379/0"
                fi
            else
                set_env REDIS_BIND_HOST "127.0.0.1"
                REMOTE_REDIS_URL="redis://<tailscale-ip>:6379/0 (after Tailscale install)"
            fi
            ;;
        2)
            info "Cloudflare Tunnel setup — see PROBE_NODE_SETUP.md for the full guide."
            info "cloudflared will run on this host and bridge a hostname to localhost:6379."
            set_env REDIS_BIND_HOST "127.0.0.1"
            REMOTE_REDIS_URL="redis://<your-tunnel-hostname>:6379/0"
            ;;
        3)
            info "Manual networking. Redis will bind to 127.0.0.1:6379 by default —"
            info "edit REDIS_BIND_HOST in .env if you need a different interface."
            set_env REDIS_BIND_HOST "127.0.0.1"
            REMOTE_REDIS_URL="redis://<your-tunnel-endpoint>:6379/0"
            ;;
    esac

    # Append the remote-probes compose override so REDIS_BIND_HOST takes effect.
    CURRENT_CF=$(grep -E '^COMPOSE_FILE=' .env 2>/dev/null | head -n1 | cut -d= -f2- || echo "")
    [ -z "$CURRENT_CF" ] && CURRENT_CF="docker-compose.yml"
    if [[ "$CURRENT_CF" != *"remote-probes"* ]]; then
        CURRENT_CF="${CURRENT_CF}:docker-compose.remote-probes.yml"
    fi
    set_env COMPOSE_FILE "$CURRENT_CF"
fi

# ── registration ────────────────────────────────────────────────────────────
header "User registration"
if confirm "Allow open user registration? (No = only the very first user can register)"; then
    set_env REGISTRATION_ENABLED "true"
else
    set_env REGISTRATION_ENABLED "false"
    info "Registration locked. The first user to sign up will be the admin."
fi

# ── mail ────────────────────────────────────────────────────────────────────
header "Email (alerts)"
echo "  1) Skip — log emails to storage/logs (no real delivery)"
echo "  2) Amazon SES"
echo "  3) Generic SMTP (Mailgun, Postmark, custom, etc.)"
echo ""
MAIL_CHOICE=""
while [[ ! "$MAIL_CHOICE" =~ ^[1-3]$ ]]; do
    read -p "  Choose [1/2/3]: " MAIL_CHOICE
done

case "$MAIL_CHOICE" in
    2)
        info "Amazon SES — make sure the from-address is on a verified identity."
        set_env MAIL_MAILER "ses"
        FROM=$(ask "From email" "alerts@${DOMAIN:-example.com}")
        FROM_NAME=$(ask "From name" "$APP_NAME")
        set_env MAIL_FROM_ADDRESS "\"$FROM\""
        set_env MAIL_FROM_NAME "\"$FROM_NAME\""
        AWS_KEY_INPUT=$(ask "AWS Access Key ID" "")
        AWS_SECRET_INPUT=$(ask_secret "AWS Secret Access Key")
        AWS_REGION_INPUT=$(ask "AWS region (e.g. us-east-1, eu-west-1)" "us-east-1")
        # AWS_* covers every AWS service (SES, S3, etc).
        # Cloudflare R2 has its own R2_* namespace below.
        set_env AWS_ACCESS_KEY_ID "$AWS_KEY_INPUT"
        set_env AWS_SECRET_ACCESS_KEY "$AWS_SECRET_INPUT"
        set_env AWS_DEFAULT_REGION "$AWS_REGION_INPUT"
        ok "SES configured."
        ;;
    3)
        set_env MAIL_MAILER "smtp"
        SMTP_HOST=$(ask "SMTP host" "smtp.example.com")
        SMTP_PORT=$(ask "SMTP port" "587")
        SMTP_USER=$(ask "SMTP username" "")
        SMTP_PASS=$(ask_secret "SMTP password")
        FROM=$(ask "From email" "alerts@${DOMAIN:-example.com}")
        FROM_NAME=$(ask "From name" "$APP_NAME")
        set_env MAIL_HOST "$SMTP_HOST"
        set_env MAIL_PORT "$SMTP_PORT"
        set_env MAIL_USERNAME "$SMTP_USER"
        set_env MAIL_PASSWORD "$SMTP_PASS"
        set_env MAIL_SCHEME "tls"
        set_env MAIL_FROM_ADDRESS "\"$FROM\""
        set_env MAIL_FROM_NAME "\"$FROM_NAME\""
        ok "SMTP configured."
        ;;
    *)
        set_env MAIL_MAILER "log"
        info "Emails will be written to storage/logs/laravel.log."
        ;;
esac

# ── pushover ────────────────────────────────────────────────────────────────
header "Pushover (push notifications)"
echo "  Pushover delivers instant push alerts to your phone/desktop. Each user"
echo "  still supplies their own user key from the Notifications settings page."
echo "  You only need the application token here (one per EasyMonitor install)."
echo ""
echo "  Create an app at: https://pushover.net/apps/build"
echo ""
if confirm "Do you already have a Pushover application token?"; then
    PUSHOVER_TOKEN=$(ask_secret "Pushover application token")
    if [ -n "$PUSHOVER_TOKEN" ]; then
        set_env PUSHOVER_APP_TOKEN "$PUSHOVER_TOKEN"
        ok "Pushover configured. Users can now add their user key at /settings/notifications."
    else
        info "Empty token — skipping. You can set PUSHOVER_APP_TOKEN in .env later."
    fi
else
    info "Skipping. Email alerts still work; add PUSHOVER_APP_TOKEN to .env later to enable Pushover."
fi

# ── object storage (R2 / S3) ────────────────────────────────────────────────
header "Object storage (status page logos)"
echo "  1) Local disk (default)"
echo "  2) Cloudflare R2"
echo "  3) Amazon S3"
echo ""
STORE_CHOICE=""
while [[ ! "$STORE_CHOICE" =~ ^[1-3]$ ]]; do
    read -p "  Choose [1/2/3]: " STORE_CHOICE
done

case "$STORE_CHOICE" in
    2)
        info "Cloudflare R2 (S3-compatible)."
        R2_ACCOUNT_INPUT=$(ask "Cloudflare account ID" "")
        R2_BUCKET_INPUT=$(ask "R2 bucket name" "")
        R2_KEY_INPUT=$(ask "R2 access key" "")
        R2_SECRET_INPUT=$(ask_secret "R2 secret key")
        R2_PUBLIC_INPUT=$(ask "Public URL (e.g. https://cdn.example.com — leave blank if private)" "")

        # R2 has its own dedicated namespace so it never collides with AWS
        # (SES, S3, etc.) credentials. The 'r2' filesystem disk in
        # config/filesystems.php reads these R2_* env vars.
        set_env FILESYSTEM_DISK "r2"
        set_env R2_ACCESS_KEY "$R2_KEY_INPUT"
        set_env R2_SECRET_KEY "$R2_SECRET_INPUT"
        set_env R2_BUCKET "$R2_BUCKET_INPUT"
        set_env R2_ENDPOINT "https://${R2_ACCOUNT_INPUT}.r2.cloudflarestorage.com"
        [ -n "$R2_PUBLIC_INPUT" ] && set_env R2_URL "$R2_PUBLIC_INPUT"
        ok "R2 configured."
        ;;
    3)
        info "Amazon S3."
        S3_BUCKET_INPUT=$(ask "S3 bucket name" "")
        # Reuse the AWS_* credentials already written by the SES flow (if any).
        # If not set yet, prompt now; otherwise just ask for the bucket.
        if ! grep -q '^AWS_ACCESS_KEY_ID=.' .env 2>/dev/null; then
            S3_KEY_INPUT=$(ask "AWS Access Key ID" "")
            S3_SECRET_INPUT=$(ask_secret "AWS Secret Access Key")
            S3_REGION_INPUT=$(ask "AWS region" "us-east-1")
            set_env AWS_ACCESS_KEY_ID "$S3_KEY_INPUT"
            set_env AWS_SECRET_ACCESS_KEY "$S3_SECRET_INPUT"
            set_env AWS_DEFAULT_REGION "$S3_REGION_INPUT"
        else
            info "Reusing AWS credentials already configured."
        fi
        set_env FILESYSTEM_DISK "s3"
        set_env AWS_BUCKET "$S3_BUCKET_INPUT"
        set_env AWS_USE_PATH_STYLE_ENDPOINT "false"
        ok "S3 configured."
        ;;
    *)
        set_env FILESYSTEM_DISK "local"
        info "Using local disk for uploads."
        ;;
esac

# ── patch production Caddyfile ──────────────────────────────────────────────
if $IS_PROD; then
    header "Patching Caddyfile.production"

    CADDYFILE="docker/caddy/Caddyfile.production"

    # Replace email and domain placeholders.
    sed -i.bak \
        -e "s/admin@your-domain.com/${ADMIN_EMAIL}/g" \
        -e "s/your-domain.com/${DOMAIN}/g" \
        "$CADDYFILE"
    rm -f "${CADDYFILE}.bak"

    ok "Caddyfile.production updated for ${DOMAIN}"

    # Ensure docker compose uses the production override automatically.
    # Merge with whatever is already set (e.g. remote-probes override added
    # earlier in this script) so we don't clobber previous choices.
    CURRENT_CF=$(grep -E '^COMPOSE_FILE=' .env 2>/dev/null | head -n1 | cut -d= -f2- || echo "")
    [ -z "$CURRENT_CF" ] && CURRENT_CF="docker-compose.yml"
    if [[ "$CURRENT_CF" != *"production.yml"* ]]; then
        # Insert production.yml right after the base compose file so its
        # overrides apply before any later overrides (e.g. remote-probes).
        CURRENT_CF=$(echo "$CURRENT_CF" | sed 's|^docker-compose.yml|docker-compose.yml:docker-compose.production.yml|')
        # Guard: if the base file wasn't first, just prepend production
        [[ "$CURRENT_CF" != *"production.yml"* ]] && CURRENT_CF="docker-compose.yml:docker-compose.production.yml:${CURRENT_CF}"
    fi
    set_env COMPOSE_FILE "$CURRENT_CF"
    ok "COMPOSE_FILE set so production overrides apply by default."
fi

# ── build & start docker ────────────────────────────────────────────────────
header "Starting Docker"

info "Building images and starting containers (this may take a few minutes)..."
docker compose up -d --build
ok "Containers up."

info "Waiting for services to become responsive..."
sleep 6

# ── run inner setup ─────────────────────────────────────────────────────────
header "Application setup"
docker compose exec -T php bash /var/www/html/docker/scripts/setup.sh

# ── apply retention policy ──────────────────────────────────────────────────
# Migrations apply the policy on first install; re-running here makes sure a
# changed CHECK_RESULT_RETENTION_DAYS also takes effect on existing installs.
info "Applying check result retention policy..."
docker compose exec -T php php artisan easymonitor:retention --no-interaction \
    || warn "Could not apply retention policy — run 'docker compose exec php php artisan easymonitor:retention' manually."

# ── recycle probe so it picks up the freshly-generated JWT_TOKEN ────────────
# The bundled probe container started before the inner setup wrote
# PROBE_JWT_TOKEN to .env; recreate it so Docker re-reads the env file.
info "Restarting probe to pick up the new token..."
docker compose up -d --force-recreate probe >/dev/null 2>&1 || true
ok "Probe restarted."

# ── done ────────────────────────────────────────────────────────────────────
header "Done"
if $IS_PROD; then
    ok "EasyMonitor is starting up at https://${DOMAIN}"
    echo ""
    echo "  Next steps:"
    if [ -n "$PUBLIC_IP" ]; then
        echo "    1. DNS A record:  ${DOMAIN}  →  ${PUBLIC_IP}"
    else
        echo "    1. Make sure DNS A record for ${DOMAIN} points to this server."
    fi
    echo "    2. Open https://${DOMAIN} — Caddy will provision the TLS cert on first request."
    echo "    3. Sign up to create the admin user (registration auto-locks after the first user)."
    echo "    4. Tail logs:  docker compose logs -f php  (or caddy / probe)"
else
    ok "EasyMonitor is running at http://localhost"
    echo ""
    echo "  Next steps:"
    echo "    1. Open http://localhost"
    echo "    2. Click Sign In → Sign up to create your account"
    echo "    3. Add your first monitor"
fi

if $REMOTE_PROBES; then
    echo ""
    echo "  Remote probes:"
    echo "    - Probe connection URL:   ${REMOTE_REDIS_URL}"
    echo "    - Generate a probe token: docker compose exec php php artisan probe:generate-token --node-id=<region>"
    echo "    - Full probe setup guide: PROBE_NODE_SETUP.md"
fi
echo ""