#!/bin/bash # ═══════════════════════════════════════════════════════════════════════════════ # check_adsb.sh — Vérification complète du stack ADS-B Linux-25 # Version 3.5 — JCA 2026 — sortie terminal + HTML # v3.3 : correction UID logs (70→999, image postgis/postgis:16-3.5 Debian) # ajout section 5c : extensions PostGIS, droits volumes, postgresql.auto.conf # vérification partitions RANGE simples (sans HASH résiduel) # v3.4 : ajout section 5d : partition semaine courante/suivante présente, continuité # v3.5 : ajout section 9b : pare-feu (ufw/firewalld), tests d'accès réel local + LAN # (aucun trou) entre bornes de partitions adsb.positions # ajout section 6b : watchdog readsb — présence crontab, fraîcheur du state # file (<10 min), RestartCount Docker (readsb/adsb2pg), détection flapping # (redémarrages répétés en 24h), vérif dérive NTP (impacte ts/dedup) # v3.5 : section 9 — ciblage précis du dongle RTL-SDR (0bda:2838) pour les # resets/déconnexions USB, au lieu de tout le bus (faux positif corrigé # le 05/07/2026 : un récepteur Logitech débranché était compté à tort) # v3.6 : généralisation multi-récepteurs (2e dongle + antenne 7dBi, ajouté le # 07/07/2026) — les sections dongle, données, ports, adsb2pg, autogain # et RestartCount parcourent maintenant le tableau RECEIVERS ci-dessous # au lieu de ne vérifier que le premier récepteur en dur # v3.7 : watchdog_readsb.sh généralisé lui aussi (v3, son propre tableau # RECEIVERS interne) — fraîcheur du state file et détection de flapping # passées par récepteur (fichiers/comptages suffixés par serial), # vérification de couverture par test du serial plutôt qu'un nom de # conteneur en dur (qui ne matchait plus depuis la généralisation) # ═══════════════════════════════════════════════════════════════════════════════ HTML_OUT="${1:-/tmp/status_adsb.html}" # chemin HTML (modifiable en arg) # ── Configuration des récepteurs ─────────────────────────────────────────────── # v4.0 (31/07/2026) : RECEIVERS n'est plus une table statique. Elle est dérivée # des dongles RÉELLEMENT énumérés sur le bus USB, mappés dans l'ordre sur le # catalogue de slots ci-dessous. Conséquence : le script ne signale plus comme # « absent » un slot légitimement non déployé, et ne peut plus confirmer la # config à elle-même (l'ancien test grep-dans-les-logs validait la valeur # configurée, pas le matériel — d'où le faux « SN:00000012 détecté »). # # Catalogue statique — slot:port_tar1090:ctn_readsb:ctn_tar1090:ctn_adsb2pg:profile # Les numéros de série ne figurent PAS ici : ils sont affectés à la détection. SLOTS=( "1:8090:adsb-readsb:adsb-tar1090:adsb-adsb2pg:rx1" "2:8091:adsb-readsb2:adsb-tar10902:adsb-adsb2pg2:rx2" "3:8092:adsb-readsb3:adsb-tar10903:adsb-adsb2pg3:rx3" ) MAX_RECEIVERS=${#SLOTS[@]} DETECT_LIB="/data/adsb/scripts/detect_dongles.sh" if [ -r "$DETECT_LIB" ]; then # shellcheck source=/dev/null source "$DETECT_LIB" else detect_serials() { rtl_test -t 2>&1 \ | sed -n 's/^[[:space:]]*[0-9]\+:.*SN:[[:space:]]*\([0-9A-Za-z]\+\).*/\1/p' } fi mapfile -t PRESENT_SERIALS < <(detect_serials) # RECEIVERS conserve le format historique "serial:ctn_readsb:port:ctn_adsb2pg" # pour rester compatible avec toutes les boucles existantes du script. RECEIVERS=() RX_ALL_CONTAINERS=() # tous les conteneurs liés à un récepteur actif RX_PORTS=() # ports à vérifier en section 4 declare -A PORT_LABELS=() PORT_CONTAINERS=() for _i in "${!PRESENT_SERIALS[@]}"; do _n=$((_i + 1)) [ "$_n" -gt "$MAX_RECEIVERS" ] && break IFS=':' read -r _slot _port _ctn_rs _ctn_t1090 _ctn_pg _profile <<< "${SLOTS[$_i]}" _sn="${PRESENT_SERIALS[$_i]}" RECEIVERS+=("${_sn}:${_ctn_rs}:${_port}:${_ctn_pg}") RX_ALL_CONTAINERS+=("$_ctn_rs" "$_ctn_t1090" "$_ctn_pg") # Ports réseau du slot : tar1090 HTTP, Beast, RAW, SBS _beast=$((30005 + (_slot - 1) * 10)) _raw=$((30002 + (_slot - 1) * 10)) _sbs=$((30003 + (_slot - 1) * 10)) _sfx=""; [ "$_slot" -gt 1 ] && _sfx=" #${_slot}" RX_PORTS+=("$_port" "$_beast" "$_raw" "$_sbs") PORT_LABELS["$_port"]="tar1090 nginx${_sfx}"; PORT_CONTAINERS["$_port"]="$_ctn_t1090" PORT_LABELS["$_beast"]="Beast TCP${_sfx}"; PORT_CONTAINERS["$_beast"]="$_ctn_rs" PORT_LABELS["$_raw"]="RAW TCP${_sfx}"; PORT_CONTAINERS["$_raw"]="$_ctn_rs" PORT_LABELS["$_sbs"]="SBS TCP${_sfx}"; PORT_CONTAINERS["$_sbs"]="$_ctn_rs" done # Services toujours présents, indépendants du nombre de dongles PORT_LABELS["8080"]="webapp PHP"; PORT_CONTAINERS["8080"]="adsb-webapp" PORT_LABELS["5432"]="PostgreSQL"; PORT_CONTAINERS["5432"]="wordpress-postgres" BASE_CONTAINERS=(adsb-webapp wordpress-postgres) BASE_PORTS=(8080 5432) # ── Couleurs terminal ────────────────────────────────────────────────────────── GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BLUE='\033[1;34m'; BOLD='\033[1m'; NC='\033[0m' ERRORS=0; WARNS=0 NOW=$(date '+%Y-%m-%dT%H:%M:%S') NOW_DISP=$(date '+%a %d/%m/%Y %H:%M:%S') IP=$(ip -4 addr show wlp58s0 2>/dev/null | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+' | head -1) [ -z "$IP" ] && IP=$(hostname -I | awk '{print $1}') # ── Buffer HTML ──────────────────────────────────────────────────────────────── HTML="" HTML_ROWS="" # lignes de détail # ── Fonctions terminal ───────────────────────────────────────────────────────── ok() { echo -e " ${GREEN}✓${NC} $1"; } fail() { echo -e " ${RED}✗${NC} $1"; ERRORS=$((ERRORS+1)); } warn() { echo -e " ${YELLOW}⚠${NC} $1"; WARNS=$((WARNS+1)); } info() { echo -e " ${CYAN}→${NC} $1"; } hdr() { echo -e "\n${BLUE}${BOLD}═══ $1 ═══${NC}"; } # ── Fonctions HTML ───────────────────────────────────────────────────────────── h_section() { HTML_ROWS+="$1"; } h_ok() { HTML_ROWS+="✓$1OK"; } h_fail() { HTML_ROWS+="✗$1ERREUR"; } h_warn() { HTML_ROWS+="⚠$1ATTENTION"; } h_info() { HTML_ROWS+="→$1"; } # ── Wrapper : terminal + HTML simultanément ─────────────────────────────────── OK() { ok "$1"; h_ok "$1"; } FAIL() { fail "$1"; h_fail "$1"; } WARN() { warn "$1"; h_warn "$1"; } INFO() { info "$1"; h_info "$1"; } HDR() { hdr "$1"; h_section "▸ $1"; } # ══════════════════════════════════════════════════════════════════════════════ # VÉRIFICATIONS # ══════════════════════════════════════════════════════════════════════════════ echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ Stack ADS-B Linux-25 — Vérification complète ║${NC}" echo -e "${BOLD}║ $NOW_DISP ║${NC}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" INFO "IP : ${IP:-inconnue} — $NOW_DISP" # ── 1. CONTENEURS ───────────────────────────────────────────────────────────── HDR "1. Conteneurs Docker" if [ ${#PRESENT_SERIALS[@]} -eq 0 ]; then FAIL "Aucun dongle RTL-SDR détecté sur le bus USB — aucun récepteur ne peut être vérifié" else INFO "${#PRESENT_SERIALS[@]} dongle(s) détecté(s) : ${PRESENT_SERIALS[*]} — slots rx1..rx${#RECEIVERS[@]} attendus" fi for ctn in "${RX_ALL_CONTAINERS[@]}" "${BASE_CONTAINERS[@]}"; do STATUS=$(docker inspect --format '{{.State.Status}}' "$ctn" 2>/dev/null) HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$ctn" 2>/dev/null) UPTIME=$(docker inspect --format '{{.State.StartedAt}}' "$ctn" 2>/dev/null | \ xargs -I{} date -d {} '+%d/%m %H:%M' 2>/dev/null) LABEL="$ctn" [ -n "$HEALTH" ] && [ "$HEALTH" != "" ] && LABEL="$ctn [$HEALTH]" if [ "$STATUS" = "running" ]; then [ "$HEALTH" = "unhealthy" ] && WARN "$LABEL — démarré $UPTIME" \ || OK "$LABEL — démarré $UPTIME" else FAIL "$ctn — STATUS=${STATUS:-absent}" fi done # ── 2. DONGLE(S) RTL-SDR ─────────────────────────────────────────────────────── HDR "2. Dongle(s) RTL-SDR" for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" # Le serial provient de l'énumération USB (detect_serials), pas des logs du # conteneur : ceux-ci ne font qu'écho à la valeur configurée et validaient # donc la config à elle-même. GAIN=$(docker logs "$RX_CTN" 2>&1 | grep "tuner gain set to" | tail -1 | grep -oP '[\d.]+(?= dB)') OK "$RX_CTN — SN:$RX_SERIAL présent sur le bus USB — gain ${GAIN:-?} dB" # Le conteneur ouvre-t-il bien CE dongle ? (détecte le vol de device # entre deux readsb, et le fallback silencieux sur l'index 0) CFG_SERIAL=$(docker inspect --format \ '{{range .Config.Env}}{{println .}}{{end}}' "$RX_CTN" 2>/dev/null \ | sed -n 's/^READSB_RTLSDR_DEVICE=//p') if [ -n "$CFG_SERIAL" ] && [ "$CFG_SERIAL" != "$RX_SERIAL" ]; then FAIL "$RX_CTN — configuré sur SN:$CFG_SERIAL mais le dongle du slot est SN:$RX_SERIAL — risque de fallback sur le device index 0 et de vol de dongle" fi if docker logs "$RX_CTN" 2>&1 | grep -qiE "usb_claim_interface error|Failed to open rtlsdr|No supported devices found"; then FAIL "$RX_CTN — échec d'ouverture du dongle dans les logs (device déjà claim, ou serial introuvable) — c'est la cause d'un compteur messages à 0" fi done ls /dev/adsb_dongle 2>/dev/null && OK "Symlink /dev/adsb_dongle présent" \ || WARN "Symlink /dev/adsb_dongle absent" # Vérifier le node USB réel pointé par le symlink (utile pour le 1er récepteur # uniquement — un seul symlink udev par convention, voir 99-adsb-docker.rules) DONGLE_TARGET=$(readlink -f /dev/adsb_dongle 2>/dev/null) if [ -n "$DONGLE_TARGET" ]; then DONGLE_PERMS=$(ls -la "$DONGLE_TARGET" 2>/dev/null | awk '{print $1, $3, $4}') INFO "Symlink → $DONGLE_TARGET ($DONGLE_PERMS)" else WARN "Impossible de résoudre /dev/adsb_dongle" fi # Vérifier que tous les numéros de série sont bien distincts (évite la # récidive de l'incident du 06/07/2026 : deux dongles avec le même serial # provoquant un crash-loop "Multiple RTL-SDR devices present") DUP_SERIALS=$(printf '%s\n' "${PRESENT_SERIALS[@]}" | sort | uniq -d) if [ -z "$DUP_SERIALS" ]; then OK "Tous les numéros de série RTL-SDR détectés sont distincts (${#RECEIVERS[@]} récepteur(s))" else FAIL "Numéro(s) de série en doublon sur le bus USB : $DUP_SERIALS — l'affectation slot↔dongle est indéterminée, reprogrammer un dongle avec rtl_eeprom -s" fi # ── 3. DONNÉES ADS-B ────────────────────────────────────────────────────────── HDR "3. Données ADS-B" for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" AC_JSON=$(curl -s --max-time 5 "http://127.0.0.1:${RX_PORT}/data/aircraft.json" 2>/dev/null) if [ -n "$AC_JSON" ]; then MSGS=$(echo "$AC_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('messages',0))" 2>/dev/null) AVIONS=$(echo "$AC_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('aircraft',[])))" 2>/dev/null) WITH_POS=$(echo "$AC_JSON" | python3 -c " import json,sys d=json.load(sys.stdin) print(sum(1 for a in d.get('aircraft',[]) if a.get('lat') and a.get('lon')))" 2>/dev/null) [ "${MSGS:-0}" -gt 0 ] 2>/dev/null \ && OK "$RX_CTN (:${RX_PORT}) actif — $(printf '%s' "$MSGS" | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta') messages décodés" \ || WARN "$RX_CTN (:${RX_PORT}) répond — 0 message (démarrage récent ?)" OK "$RX_CTN — $AVIONS avion(s) en vue dont $WITH_POS avec position GPS" else FAIL "$RX_CTN ne répond pas sur :${RX_PORT}/data/aircraft.json" fi # Détection dongle figé : compteur "messages" comparé au relevé précédent # (indépendant du watchdog cron — utile pour un diagnostic immédiat manuel) MSGS_STATE_FILE="/tmp/check_adsb_last_messages_${RX_SERIAL}" if [ -n "$MSGS" ]; then PREV_MSGS=$(cat "$MSGS_STATE_FILE" 2>/dev/null || echo -1) PREV_TIME=$(stat -c %Y "$MSGS_STATE_FILE" 2>/dev/null || echo 0) ELAPSED=$(( $(date +%s) - PREV_TIME )) if [ "$PREV_MSGS" != "-1" ] && [ "$MSGS" = "$PREV_MSGS" ] && [ "$ELAPSED" -gt 120 ]; then # aircraft.json est servi par tar1090, pas par readsb : un compteur # figé peut venir du récepteur OU du lien tar1090→readsb. On # interroge directement le port SBS pour trancher (incident 31/07 : # readsb décodait, tar1090 servait 0 message, faute d'alias réseau). RX_SBS=$((30003 + (RX_PORT - 8090) * 10)) SBS_BYTES=$(timeout 6 nc 127.0.0.1 "$RX_SBS" 2>/dev/null | head -c 400 | wc -c) if [ "${SBS_BYTES:-0}" -ge 50 ]; then FAIL "$RX_CTN — aircraft.json figé à $MSGS depuis ${ELAPSED}s MAIS le port SBS $RX_SBS émet ($SBS_BYTES octets) — readsb est sain, le maillon en cause est tar1090 (alias réseau BEASTHOST perdu ?)" else FAIL "$RX_CTN — compteur messages figé à $MSGS depuis ${ELAPSED}s et port SBS $RX_SBS silencieux — vérifier gain, antenne et connecteur avant de redémarrer $RX_CTN" fi elif [ "$PREV_MSGS" != "-1" ]; then OK "$RX_CTN — compteur messages en progression ($PREV_MSGS → $MSGS depuis ${ELAPSED}s)" else INFO "$RX_CTN — premier relevé du compteur messages ($MSGS)" fi echo "$MSGS" > "$MSGS_STATE_FILE" fi done VOL_PATH=$(docker inspect adsb_readsb-run 2>/dev/null | \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['Mountpoint'])" 2>/dev/null) if [ -n "$VOL_PATH" ] && [ -f "$VOL_PATH/aircraft.pb" ]; then AGE=$(( $(date +%s) - $(stat -c %Y "$VOL_PATH/aircraft.pb") )) [ "$AGE" -lt 10 ] && OK "Volume readsb-run : aircraft.pb frais (${AGE}s)" \ || WARN "Volume readsb-run : aircraft.pb âgé de ${AGE}s" else WARN "Volume readsb-run : aircraft.pb introuvable (mode host normal)" fi # ── 4. PORTS RÉSEAU ─────────────────────────────────────────────────────────── HDR "4. Ports réseau" # Fonction : résoudre quel conteneur écoute sur un port port_owner() { local port=$1 # Chercher via docker ps les ports exposés docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | \ grep ":${port}->" | awk '{print $1}' | head -1 } # Fonction : résoudre l'IP d'écoute d'un port port_addr() { local port=$1 ss -tlnp 2>/dev/null | grep ":${port}[[:space:]]" | \ awk '{print $4}' | head -1 } # PORT_LABELS / PORT_CONTAINERS sont désormais construits en tête de script, # à partir des slots réellement actifs (voir « Configuration des récepteurs »). for port in "${BASE_PORTS[@]}" "${RX_PORTS[@]}"; do ADDR=$(port_addr "$port") CTN="${PORT_CONTAINERS[$port]}" if [ -n "$ADDR" ]; then OK "Port $port en écoute sur $ADDR — ${PORT_LABELS[$port]} ($CTN)" else FAIL "Port $port absent — ${PORT_LABELS[$port]} ($CTN)" fi done # IPs des conteneurs sur leurs réseaux echo "" READSB_IP=$(docker inspect adsb-readsb 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] nets=d['NetworkSettings']['Networks'] for n,v in nets.items(): if v['IPAddress']: print(f\"{n}:{v['IPAddress']}\")" 2>/dev/null) TAR1090_IP=$(docker inspect adsb-tar1090 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] nets=d['NetworkSettings']['Networks'] parts=[] for n,v in nets.items(): ip=v.get('IPAddress','') if ip: parts.append(f'{n}:{ip}') print(' | '.join(parts) if parts else 'host')" 2>/dev/null) ADSB2PG_NET=$(docker inspect adsb-adsb2pg 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] m=d['HostConfig'].get('NetworkMode','') print(m)" 2>/dev/null) WEBAPP_NET=$(docker inspect adsb-webapp 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] m=d['HostConfig'].get('NetworkMode','') print(m)" 2>/dev/null) [ -n "$READSB_IP" ] \ && OK "adsb-readsb : $READSB_IP" \ || FAIL "adsb-readsb : aucune IP réseau" INFO "adsb-tar1090 : ${TAR1090_IP:-host}" INFO "adsb-adsb2pg : mode=${ADSB2PG_NET:-?}" INFO "adsb-webapp : mode=${WEBAPP_NET:-?}" # Test Beast TCP tar1090 → readsb FAIL_CNT=$(docker logs adsb-tar1090 2>&1 | grep "Beast TCP input.*failed" | \ tail -1 | grep -oP '\d+(?= times)' || echo 0) [ "${FAIL_CNT:-0}" -gt 50 ] 2>/dev/null \ && FAIL "Beast TCP tar1090→readsb : $FAIL_CNT échecs" \ || OK "Connexion Beast tar1090→readsb OK" # ── 4b. TESTS RÉSEAU SUPPLÉMENTAIRES ────────────────────────────────────────── HDR "4b. Connectivité réseau inter-conteneurs" # Test HTTP tar1090 depuis l'hôte HTTP_TAR=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:8090/" 2>/dev/null) [ "$HTTP_TAR" = "200" ] \ && OK "HTTP tar1090 (127.0.0.1:8090) : HTTP $HTTP_TAR" \ || FAIL "HTTP tar1090 (127.0.0.1:8090) : HTTP ${HTTP_TAR:-000}" # Test aircraft.json depuis tar1090 HTTP_AC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:8090/data/aircraft.json" 2>/dev/null) [ "$HTTP_AC" = "200" ] \ && OK "aircraft.json tar1090 (127.0.0.1:8090) : HTTP $HTTP_AC" \ || FAIL "aircraft.json tar1090 (127.0.0.1:8090) : HTTP ${HTTP_AC:-000}" # Test API readsb (port 30053 si présent, sinon skip) HTTP_API=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://127.0.0.1:30053/data/aircraft.json" 2>/dev/null) if [ "$HTTP_API" = "200" ]; then OK "API readsb (127.0.0.1:30053/data/aircraft.json) : HTTP $HTTP_API" elif [ "$HTTP_API" = "000" ]; then INFO "API readsb port 30053 : non exposé (normal en mode /data/adsb)" else WARN "API readsb (127.0.0.1:30053) : HTTP ${HTTP_API}" fi # Test Beast TCP depuis l'hôte vers readsb nc -z -w3 127.0.0.1 30005 2>/dev/null \ && OK "Beast TCP hôte→readsb (127.0.0.1:30005) : connexion OK" \ || FAIL "Beast TCP hôte→readsb (127.0.0.1:30005) : timeout" # Test RAW TCP depuis l'hôte nc -z -w3 127.0.0.1 30002 2>/dev/null \ && OK "RAW TCP hôte→readsb (127.0.0.1:30002) : connexion OK" \ || FAIL "RAW TCP hôte→readsb (127.0.0.1:30002) : timeout" # Test SBS TCP depuis l'hôte nc -z -w3 127.0.0.1 30003 2>/dev/null \ && OK "SBS TCP hôte→readsb (127.0.0.1:30003) : connexion OK" \ || FAIL "SBS TCP hôte→readsb (127.0.0.1:30003) : timeout" # Test PostgreSQL depuis l'hôte nc -z -w3 127.0.0.1 5432 2>/dev/null \ && OK "PostgreSQL hôte→wordpress-postgres (127.0.0.1:5432) : connexion OK" \ || FAIL "PostgreSQL hôte→wordpress-postgres (127.0.0.1:5432) : timeout" # Test adsb2pg → tar1090 (network_mode host, URL configurée) DUMP_URL=$(docker inspect adsb-adsb2pg 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] envs={e.split('=',1)[0]:e.split('=',1)[1] for e in d['Config']['Env'] if '=' in e} print(envs.get('DUMP1090_URL',''))" 2>/dev/null) if [ -n "$DUMP_URL" ]; then HTTP_DUMP=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$DUMP_URL" 2>/dev/null) [ "$HTTP_DUMP" = "200" ] \ && OK "adsb2pg DUMP1090_URL ($DUMP_URL) : HTTP $HTTP_DUMP" \ || FAIL "adsb2pg DUMP1090_URL ($DUMP_URL) : HTTP ${HTTP_DUMP:-000}" else WARN "DUMP1090_URL non trouvée dans adsb-adsb2pg" fi # Test accès webapp depuis LAN (IP réelle) HTTP_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8080/" 2>/dev/null) [ "$HTTP_LAN" = "200" ] \ && OK "Webapp LAN (http://${IP}:8080) : HTTP $HTTP_LAN" \ || WARN "Webapp LAN (http://${IP}:8080) : HTTP ${HTTP_LAN:-000}" # Test accès tar1090 depuis LAN HTTP_TAR_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8090/" 2>/dev/null) [ "$HTTP_TAR_LAN" = "200" ] \ && OK "tar1090 LAN (http://${IP}:8090) : HTTP $HTTP_TAR_LAN" \ || WARN "tar1090 LAN (http://${IP}:8090) : HTTP ${HTTP_TAR_LAN:-000}" # Vérifier bridge-nf-call-iptables (doit être 0 pour éviter le blocage ICC) BNF=$(cat /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null) [ "${BNF:-1}" = "0" ] \ && OK "bridge-nf-call-iptables=0 (trafic inter-conteneurs non filtré)" \ || WARN "bridge-nf-call-iptables=${BNF} (risque de blocage ICC Docker)" # Réseau adsb-net : vérifier les conteneurs attachés ADSB_NET_CTNS=$(docker network inspect adsb-net 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] ctns=d.get('Containers',{}) parts=[f\"{v['Name']}={v['IPv4Address']}\" for v in ctns.values()] print(' | '.join(parts))" 2>/dev/null) [ -n "$ADSB_NET_CTNS" ] \ && INFO "adsb-net membres : $ADSB_NET_CTNS" \ || WARN "adsb-net : aucun conteneur attaché" # ── 5. POSTGRESQL ───────────────────────────────────────────────────────────── HDR "5. PostgreSQL — schéma adsb" PG="docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c" PG_VER=$($PG "SELECT version();" 2>/dev/null | head -1 | xargs) [ -n "$PG_VER" ] && OK "PostgreSQL accessible — ${PG_VER:0:55}" || FAIL "PostgreSQL inaccessible" PART_POS=$($PG "SELECT COUNT(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='adsb' AND c.relname LIKE 'positions_2%' AND c.relkind='r';" 2>/dev/null | xargs) # Depuis v4.1 : plus de sous-partitionnement HASH, chaque semaine est une table simple (relkind='r') # Attendu : entre 2 et 6 selon la durée de collecte (une partition par semaine) if [ "${PART_POS:-0}" -ge 1 ] && [ "${PART_POS:-0}" -le 10 ] 2>/dev/null; then OK "$PART_POS partition(s) RANGE adsb.positions (schema v4.1 — sans HASH)" elif [ "${PART_POS:-0}" -gt 10 ] 2>/dev/null; then WARN "$PART_POS partitions adsb.positions — nombre élevé, vérifier qu'il n'y a pas de sous-partitions HASH résiduelles" else WARN "Aucune partition adsb.positions — adsb2pg ne peut pas insérer" fi # Vérifier qu'il ne reste pas de sous-partitions HASH résiduelles (relkind='p' = table partitionnée) HASH_RESIDUAL=$($PG "SELECT COUNT(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='adsb' AND c.relname LIKE 'positions_2%' AND c.relkind='p';" 2>/dev/null | xargs) [ "${HASH_RESIDUAL:-0}" -gt 0 ] 2>/dev/null \ && FAIL "$HASH_RESIDUAL partition(s) adsb.positions toujours de type 'p' (partitionnée) — sous-partitionnement HASH résiduel, migration incomplète" \ || OK "Aucune sous-partition HASH résiduelle sur adsb.positions" # ── 5d. CONTINUITÉ DU PARTITIONNEMENT (nouveau v3.4) ────────────────────────── # But : détecter à l'avance une absence de partition semaine courante/suivante # (cause racine possible d'un échec d'insertion silencieux côté adsb2pg) et tout # trou entre les bornes de partitions successives (perte de données silencieuse # si une ligne tombe dans un intervalle non couvert). CURRENT_WEEK_TAG=$(date +%G_w%V) NEXT_WEEK_TAG=$(date -d '+7 days' +%G_w%V) # La recherche se fait sur les BORNES, pas sur le nom : create_week_partition # a longtemps nommé les partitions avec EXTRACT(YEAR) au lieu d'ISOYEAR, donc # le nom peut diverger de la semaine ISO réellement couverte (ex. constaté : # positions_2028_w52 couvre en fait 2027-12-27 → 2028-01-03). # relkind n'est plus filtré sur 'r' : une partition encore sous-partitionnée en # HASH est de type 'p' et reste parfaitement insérable — l'ancien filtre # produisait un « ABSENTE » alarmant sur une partition présente et fonctionnelle. part_for_week() { # $1 = décalage en jours (0 = semaine courante) $PG "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'adsb.positions'::regclass AND pg_get_expr(c.relpartbound, c.oid) LIKE '%FROM (''' || (date_trunc('week', now()) + interval '$1 days')::date || '%' LIMIT 1;" 2>/dev/null | xargs } part_kind() { # $1 = nom de partition $PG "SELECT relkind FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'adsb' AND c.relname = '$1';" 2>/dev/null | xargs } CUR_NAME=$(part_for_week 0) if [ -n "$CUR_NAME" ]; then CUR_KIND=$(part_kind "$CUR_NAME") if [ "$CUR_KIND" = "p" ]; then CUR_SUB=$($PG "SELECT count(*) FROM pg_inherits WHERE inhparent='adsb.${CUR_NAME}'::regclass;" 2>/dev/null | xargs) if [ "${CUR_SUB:-0}" -gt 0 ]; then OK "Partition semaine courante présente : $CUR_NAME (encore sous-partitionnée HASH, ${CUR_SUB} enfants — insérable)" else FAIL "Partition semaine courante $CUR_NAME est de type 'p' SANS sous-partition — toute insertion échouera (no partition of relation found)" fi else OK "Partition semaine courante présente : $CUR_NAME (RANGE simple)" fi [ "$CUR_NAME" != "positions_${CURRENT_WEEK_TAG}" ] && \ WARN "Nom de partition incohérent avec la semaine ISO : $CUR_NAME au lieu de positions_${CURRENT_WEEK_TAG} — create_week_partition utilise EXTRACT(YEAR) au lieu d'EXTRACT(ISOYEAR)" else FAIL "Aucune partition ne couvre la semaine courante — adsb2pg ne peut plus insérer !" fi NEXT_NAME=$(part_for_week 7) if [ -n "$NEXT_NAME" ]; then OK "Partition semaine suivante déjà créée : $NEXT_NAME" else WARN "Aucune partition ne couvre la semaine suivante (positions_${NEXT_WEEK_TAG}) — à créer avant le rollover (SELECT adsb.create_week_partition(...))" fi GAP_COUNT=$($PG " WITH bounds AS ( SELECT c.relname, (regexp_match(pg_get_expr(c.relpartbound, c.oid), 'FROM \(''([^'']+)''\) TO \(''([^'']+)''\)'))[1]::timestamptz AS lo, (regexp_match(pg_get_expr(c.relpartbound, c.oid), 'FROM \(''([^'']+)''\) TO \(''([^'']+)''\)'))[2]::timestamptz AS hi FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'adsb.positions'::regclass AND c.relkind='r' ), ordered AS ( SELECT lo, hi, LEAD(lo) OVER (ORDER BY lo) AS next_lo FROM bounds ) SELECT COUNT(*) FROM ordered WHERE next_lo IS NOT NULL AND next_lo <> hi; " 2>/dev/null | xargs) [ "${GAP_COUNT:-0}" -eq 0 ] 2>/dev/null \ && OK "Continuité des partitions positions vérifiée (aucun trou entre semaines)" \ || FAIL "${GAP_COUNT:-?} trou(s) détecté(s) entre bornes de partitions positions — risque de perte silencieuse de données" PART_HIS=$($PG "SELECT COUNT(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='adsb' AND c.relname LIKE 'aircraft_history_%' AND c.relkind='r';" 2>/dev/null | xargs) OK "${PART_HIS:-0} sous-partitions adsb.aircraft_history" # Vérifier que chaque récepteur configuré (RECEIVERS) est bien enregistré et # actif dans adsb.receivers — sinon ses positions s'insèrent quand même mais # avec un receiver_id incohérent ou l'auto-enregistrement générique d'adsb2pg # (nom/coordonnées vides) plutôt que les vraies métadonnées. for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" RX_ROW=$($PG "SELECT active, name FROM adsb.receivers WHERE serial='${RX_SERIAL}';" 2>/dev/null) if [ -z "$(echo "$RX_ROW" | xargs)" ]; then FAIL "Récepteur $RX_SERIAL absent de adsb.receivers — positions orphelines côté FK" else RX_ACTIVE=$(echo "$RX_ROW" | awk -F'|' '{print $1}' | xargs) RX_NAME=$(echo "$RX_ROW" | awk -F'|' '{print $2}' | xargs) [ "$RX_ACTIVE" = "t" ] \ && OK "Récepteur $RX_SERIAL enregistré et actif — \"$RX_NAME\"" \ || WARN "Récepteur $RX_SERIAL enregistré mais INACTIF (active=false) — \"$RX_NAME\"" fi done POS_5MIN=$($PG "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | xargs) [ "${POS_5MIN:-0}" -gt 0 ] 2>/dev/null \ && OK "$POS_5MIN positions insérées dans les 5 dernières minutes" \ || WARN "Aucune position dans les 5 dernières minutes" PCT_DESC=$($PG "SELECT ROUND(COUNT(aircraft_desc)*100.0/NULLIF(COUNT(*),0),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | xargs) python3 -c "exit(0 if float('${PCT_DESC:-0}') >= 80 else 1)" 2>/dev/null \ && OK "aircraft_desc rempli à ${PCT_DESC}% sur 30 min" \ || WARN "aircraft_desc rempli à ${PCT_DESC:-0}% (< 80%)" AV_TODAY=$($PG "SELECT COUNT(DISTINCT icao) FROM adsb.aircraft_history WHERE session_start >= CURRENT_DATE;" 2>/dev/null | xargs) SESS_TODAY=$($PG "SELECT COUNT(*) FROM adsb.aircraft_history WHERE session_start >= CURRENT_DATE;" 2>/dev/null | xargs) INFO "Aujourd'hui : ${AV_TODAY:-0} avions distincts, ${SESS_TODAY:-0} sessions" DB_SIZE=$($PG "SELECT pg_size_pretty(SUM(pg_total_relation_size(c.oid))) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='adsb';" 2>/dev/null | xargs) INFO "Taille totale schéma adsb : ${DB_SIZE:-?}" # Connexions actives PostgreSQL PG_CONNS=$($PG "SELECT COUNT(*) FROM pg_stat_activity WHERE state='active';" 2>/dev/null | xargs) INFO "Connexions PostgreSQL actives : ${PG_CONNS:-?}" # ── 5b. VOLUMES POSTGRESQL ──────────────────────────────────────────────────── HDR "5b. Volumes PostgreSQL (conf & logs)" PG_CONF_VOL="pg-conf-std" PG_LOGS_VOL="pg-logs-std" # ── Existence des volumes ── for VOL in "$PG_CONF_VOL" "$PG_LOGS_VOL"; do VOL_INFO=$(docker volume inspect "$VOL" 2>/dev/null) if [ -n "$VOL_INFO" ]; then VOL_MP=$(echo "$VOL_INFO" | python3 -c " import json,sys d=json.load(sys.stdin)[0] print(d.get('Mountpoint','?'))" 2>/dev/null) OK "Volume Docker '$VOL' existe — Mountpoint : $VOL_MP" else FAIL "Volume Docker '$VOL' absent — conf/logs PostgreSQL non accessibles depuis l'hôte" fi done # ── Mountpoints ── MP_CONF=$(docker volume inspect "$PG_CONF_VOL" --format '{{.Mountpoint}}' 2>/dev/null) MP_LOGS=$(docker volume inspect "$PG_LOGS_VOL" --format '{{.Mountpoint}}' 2>/dev/null) # ── Liens symboliques pratiques ── LINK_CONF="/data/adsb/postgres/conf" LINK_LOGS="/data/adsb/postgres/logs" for LINK in "$LINK_CONF" "$LINK_LOGS"; do if [ -L "$LINK" ]; then LINK_TARGET=$(readlink -f "$LINK" 2>/dev/null) OK "Lien symbolique $LINK → $LINK_TARGET" elif [ -d "$LINK" ]; then INFO "$LINK existe (répertoire, pas un lien symbolique)" else WARN "Lien symbolique $LINK absent — créer avec : ln -sfn \$MOUNTPOINT $LINK" fi done # ── Fichier de configuration ── if [ -n "$MP_CONF" ]; then CONF_FILE="$MP_CONF/postgresql.conf" if [ -f "$CONF_FILE" ]; then CONF_PERMS=$(stat -c "%a %U:%G" "$CONF_FILE" 2>/dev/null) CONF_SIZE=$(wc -c < "$CONF_FILE" 2>/dev/null) CONF_AGE=$(( $(date +%s) - $(stat -c %Y "$CONF_FILE" 2>/dev/null || echo 0) )) OK "postgresql.conf présent ($CONF_SIZE octets, modifié il y a ${CONF_AGE}s, droits $CONF_PERMS)" # Vérifier lisibilité par l'utilisateur courant if [ -r "$CONF_FILE" ]; then OK "postgresql.conf lisible depuis l'hôte" else WARN "postgresql.conf non lisible depuis l'hôte (droits insuffisants)" fi # Vérifier paramètres clés dans le fichier MAX_LOCKS=$(grep -E "^max_locks_per_transaction\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d ' ') LOG_DIR=$(grep -E "^log_directory\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d " '") LOG_COL=$(grep -E "^logging_collector\s*=" "$CONF_FILE" 2>/dev/null | awk -F= '{print $2}' | tr -d ' ') [ -n "$MAX_LOCKS" ] && INFO "max_locks_per_transaction = $MAX_LOCKS (fichier conf)" \ || INFO "max_locks_per_transaction non défini dans le fichier (via command ?)" [ -n "$LOG_DIR" ] && INFO "log_directory = $LOG_DIR" \ || INFO "log_directory non défini dans le fichier" [ "$LOG_COL" = "on" ] && OK "logging_collector = on" \ || WARN "logging_collector non défini ou off dans le fichier conf" else FAIL "postgresql.conf absent dans le volume $PG_CONF_VOL ($MP_CONF/)" fi # Droits du répertoire conf CONF_DIR_OWNER=$(stat -c "%U:%G (%a)" "$MP_CONF" 2>/dev/null) INFO "Répertoire conf : $MP_CONF — droits $CONF_DIR_OWNER" else WARN "Mountpoint du volume $PG_CONF_VOL non résolu" fi # ── Répertoire de logs ── if [ -n "$MP_LOGS" ]; then # Droits du répertoire logs # UID attendu : 999 = postgres dans l'image postgis/postgis:16-3.5 (Debian) # (était 70 sous postgres:16-alpine, remplacée en v4.1 le 30/06/2026) LOGS_OWNER_UID=$(stat -c "%u" "$MP_LOGS" 2>/dev/null) LOGS_OWNER_NAME=$(stat -c "%U:%G (%a)" "$MP_LOGS" 2>/dev/null) if [ "${LOGS_OWNER_UID:-0}" = "999" ]; then OK "Répertoire logs : propriétaire uid=999 (postgres Debian) — $LOGS_OWNER_NAME" else FAIL "Répertoire logs : propriétaire uid=${LOGS_OWNER_UID:-?} (attendu 999/postgres Debian) — PostgreSQL ne peut pas écrire les logs (corriger : docker exec -u root wordpress-postgres chown -R 999:999 /var/log/postgresql)" fi # Lisibilité depuis l'hôte (root peut lire même si mode 750) if [ -r "$MP_LOGS" ]; then OK "Répertoire logs lisible depuis l'hôte (root)" else WARN "Répertoire logs non lisible depuis l'hôte" fi # Fichiers de logs présents LOG_TODAY="$MP_LOGS/postgresql-$(date +%Y-%m-%d).log" LOG_COUNT=$(ls "$MP_LOGS"/postgresql-*.log 2>/dev/null | wc -l) if [ "$LOG_COUNT" -gt 0 ]; then OK "$LOG_COUNT fichier(s) de log PostgreSQL présent(s) dans le volume" if [ -f "$LOG_TODAY" ]; then LOG_SIZE=$(wc -c < "$LOG_TODAY" 2>/dev/null) LOG_AGE=$(( $(date +%s) - $(stat -c %Y "$LOG_TODAY" 2>/dev/null || echo 0) )) [ "$LOG_AGE" -lt 300 ] \ && OK "Log du jour actif : postgresql-$(date +%Y-%m-%d).log (${LOG_SIZE} octets, mis à jour il y a ${LOG_AGE}s)" \ || WARN "Log du jour présent mais inactif depuis ${LOG_AGE}s (PostgreSQL ne loggue plus ?)" else WARN "Pas de fichier log pour aujourd'hui ($(date +%Y-%m-%d)) — logging_collector actif ?" fi # Dernière ligne de log LAST_LOG_LINE=$(tail -1 "$LOG_TODAY" 2>/dev/null | cut -c1-120) [ -n "$LAST_LOG_LINE" ] && INFO "Dernière ligne log : $LAST_LOG_LINE" else WARN "Aucun fichier de log PostgreSQL dans le volume $PG_LOGS_VOL — logging_collector activé ?" fi # Fichiers CSV CSV_COUNT=$(ls "$MP_LOGS"/postgresql-*.csv 2>/dev/null | wc -l) [ "$CSV_COUNT" -gt 0 ] && INFO "$CSV_COUNT fichier(s) CSV de log présent(s)" INFO "Accès direct aux logs : tail -f $MP_LOGS/postgresql-\$(date +%Y-%m-%d).log" else WARN "Mountpoint du volume $PG_LOGS_VOL non résolu" fi # ── Vérification croisée : PostgreSQL utilise bien ces volumes ── PG_VOL_CHECK=$(docker inspect wordpress-postgres 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin)[0] mounts=d.get('Mounts',[]) names=[m.get('Name','') for m in mounts] print(' | '.join(names))" 2>/dev/null) INFO "Volumes montés dans wordpress-postgres : ${PG_VOL_CHECK:-aucun}" # Vérifier que pg-conf-std et pg-logs-std sont bien montés for VOL in "$PG_CONF_VOL" "$PG_LOGS_VOL"; do if echo "$PG_VOL_CHECK" | grep -q "$VOL"; then OK "Volume '$VOL' monté dans wordpress-postgres" else FAIL "Volume '$VOL' NON monté dans wordpress-postgres — redémarrer le conteneur" fi done # Vérifier config_file via PostgreSQL (si accessible) PG_CONFIG_FILE=$($PG "SHOW config_file;" 2>/dev/null | xargs) PG_LOG_DIR=$($PG "SHOW log_directory;" 2>/dev/null | xargs) PG_MAX_LOCKS=$($PG "SHOW max_locks_per_transaction;" 2>/dev/null | xargs) PG_LOG_COL=$($PG "SHOW logging_collector;" 2>/dev/null | xargs) [ -n "$PG_CONFIG_FILE" ] \ && OK "PostgreSQL config_file actif : $PG_CONFIG_FILE" \ || WARN "Impossible de lire config_file depuis PostgreSQL" [ -n "$PG_LOG_DIR" ] && INFO "PostgreSQL log_directory : $PG_LOG_DIR" [ -n "$PG_MAX_LOCKS" ] \ && OK "max_locks_per_transaction (runtime) : $PG_MAX_LOCKS" \ || WARN "max_locks_per_transaction non lisible" [ "${PG_LOG_COL}" = "on" ] \ && OK "logging_collector = on (runtime)" \ || WARN "logging_collector = ${PG_LOG_COL:-?} (runtime) — logs fichier peut-être inactifs" # ── 5c. POSTGRESQL — EXTENSIONS ET PARAMÈTRES CRITIQUES ────────────────────── HDR "5c. PostgreSQL — extensions et paramètres critiques" # ── Extensions installées ── # postgis : requis depuis v4.1 (image postgis/postgis:16-3.5, colonne geom sur adsb.positions) PG_EXT_POSTGIS=$($PG "SELECT installed_version FROM pg_available_extensions WHERE name='postgis';" 2>/dev/null | xargs) if [ -n "$PG_EXT_POSTGIS" ] && [ "$PG_EXT_POSTGIS" != "" ]; then OK "Extension postgis installée — version $PG_EXT_POSTGIS" else FAIL "Extension postgis absente — requis depuis v4.1 (image postgis/postgis:16-3.5)" fi # Vérifier que la colonne geom est bien présente sur la table parente adsb.positions GEOM_COL=$($PG "SELECT column_name FROM information_schema.columns WHERE table_schema='adsb' AND table_name='positions' AND column_name='geom';" 2>/dev/null | xargs) [ "$GEOM_COL" = "geom" ] \ && OK "Colonne geom présente sur adsb.positions (GENERATED ALWAYS, héritage partitions)" \ || FAIL "Colonne geom absente sur adsb.positions — PostGIS non appliqué (ALTER TABLE manquant ?)" # Index GiST : vérifier sa présence sur la partition active (semaine courante) # Index GiST : vérifier sa présence sur la partition qui couvre RÉELLEMENT # maintenant. L'ancien « ORDER BY c.relname DESC LIMIT 1 » retournait la # dernière partition par ordre alphabétique — soit la plus lointaine dans le # futur (positions_2028_w52), jamais celle en cours d'écriture. ACTIVE_PART=$($PG "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'adsb.positions'::regclass AND pg_get_expr(c.relpartbound, c.oid) LIKE '%FROM (''' || date_trunc('week', now())::date || '%' LIMIT 1;" 2>/dev/null | xargs) if [ -n "$ACTIVE_PART" ]; then GIST_IDX=$($PG "SELECT indexname FROM pg_indexes WHERE schemaname='adsb' AND tablename='${ACTIVE_PART}' AND indexname LIKE '%geom_gix%';" 2>/dev/null | xargs) [ -n "$GIST_IDX" ] \ && OK "Index GiST sur geom présent sur la partition active ($ACTIVE_PART) — requêtes bbox optimisées" \ || WARN "Index GiST sur geom absent sur $ACTIVE_PART — créer : CREATE INDEX ${ACTIVE_PART}_geom_gix ON adsb.${ACTIVE_PART} USING GIST (geom);" INFO "Partition active : $ACTIVE_PART" else WARN "Aucune partition active trouvée pour adsb.positions" fi # ── Vérification des droits sur les volumes (UID 999, image Debian postgis/postgis) ── # Depuis le passage de postgres:16-alpine (UID 70) à postgis/postgis:16-3.5 (UID 999) # le 30/06/2026, l'ownership doit être 999:999 sur conf, logs ET data. PG_DATA_VOL="postgres_data" PG_DATA_MP=$(docker volume inspect "$PG_DATA_VOL" --format '{{.Mountpoint}}' 2>/dev/null) for VOL_CHECK in "pg-conf-std:$MP_CONF" "pg-logs-std:$MP_LOGS" "postgres_data:$PG_DATA_MP"; do VOL_NAME="${VOL_CHECK%%:*}" VOL_PATH="${VOL_CHECK##*:}" if [ -n "$VOL_PATH" ] && [ -d "$VOL_PATH" ]; then VOL_UID=$(stat -c "%u" "$VOL_PATH" 2>/dev/null) VOL_OWNER=$(stat -c "%U:%G (%a)" "$VOL_PATH" 2>/dev/null) if [ "${VOL_UID}" = "999" ] || [ "${VOL_UID}" = "0" ]; then # uid=0 (root) est acceptable pour pg-conf-std (fichier conf lisible par tous) OK "Volume $VOL_NAME : propriétaire $VOL_OWNER — OK pour image Debian (uid=${VOL_UID})" else FAIL "Volume $VOL_NAME : uid=${VOL_UID} (attendu 999 pour postgis/postgis:16-3.5) — lancer fix_permissions.sh si problème de démarrage" fi else INFO "Volume $VOL_NAME : mountpoint non résolu ou absent" fi done # ── postgresql.auto.conf — paramètres effectifs (prime sur postgresql.conf) ── # Ce fichier est géré par ALTER SYSTEM et est chargé EN DERNIER par PostgreSQL. # Toute modification via ALTER SYSTEM SET écrase postgresql.conf silencieusement. PG_AUTO_CONF_PATH="$PG_DATA_MP/postgresql.auto.conf" if [ -f "$PG_AUTO_CONF_PATH" ] && [ -r "$PG_AUTO_CONF_PATH" ]; then OK "postgresql.auto.conf accessible ($PG_AUTO_CONF_PATH)" INFO "Contenu (paramètres ALTER SYSTEM, prioritaires sur postgresql.conf) :" while IFS= read -r line; do # Ignorer commentaires et lignes vides [[ "$line" =~ ^#.*$ ]] && continue [[ -z "$line" ]] && continue INFO " auto.conf → $line" done < "$PG_AUTO_CONF_PATH" else INFO "postgresql.auto.conf non lisible depuis l'hôte (normal si volume postgres_data non accessible)" fi # ── Vérification des paramètres effectifs critiques (runtime via pg_settings) ── PG_SHARED_BUF=$($PG "SELECT pg_size_pretty(current_setting('shared_buffers')::bigint * 8192);" 2>/dev/null | xargs) PG_WORK_MEM=$($PG "SELECT pg_size_pretty(current_setting('work_mem')::bigint * 1024);" 2>/dev/null | xargs) PG_RANDOM_PC=$($PG "SHOW random_page_cost;" 2>/dev/null | xargs) PG_IO_CONC=$($PG "SHOW effective_io_concurrency;" 2>/dev/null | xargs) PG_EFF_CACHE=$($PG "SELECT pg_size_pretty(current_setting('effective_cache_size')::bigint * 8192);" 2>/dev/null | xargs) [ -n "$PG_SHARED_BUF" ] && INFO "shared_buffers (runtime) : $PG_SHARED_BUF (attendu ~3200 MB)" [ -n "$PG_WORK_MEM" ] && INFO "work_mem (runtime) : $PG_WORK_MEM (attendu 32 MB — risque OOM si > 64 MB)" [ -n "$PG_EFF_CACHE" ] && INFO "effective_cache_size (runtime) : $PG_EFF_CACHE" # Alarme si work_mem anormalement haut (vestige d'un tuning d'urgence) PG_WORK_MEM_KB=$($PG "SELECT current_setting('work_mem')::bigint;" 2>/dev/null | xargs) if [ -n "$PG_WORK_MEM_KB" ] && [ "$PG_WORK_MEM_KB" -gt 65536 ] 2>/dev/null; then WARN "work_mem = ${PG_WORK_MEM} — trop élevé pour ce hardware (i7-7567U/16Go), risque d'OOM (recommandé : 32MB)" else OK "work_mem dans les limites acceptables (${PG_WORK_MEM:-?})" fi [ -n "$PG_RANDOM_PC" ] && INFO "random_page_cost (runtime) : $PG_RANDOM_PC (attendu 1.1 pour SSD)" [ -n "$PG_IO_CONC" ] && INFO "effective_io_concurrency (runtime) : $PG_IO_CONC (attendu 150 pour SSD)" # ── 6. ADSB2PG ──────────────────────────────────────────────────────────────── HDR "6. adsb2pg — collecteur(s)" for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" LAST_INS=$(docker logs "$RX_PG" 2>&1 | grep "positions insérées" | tail -1) if [ -n "$LAST_INS" ]; then OK "$RX_PG actif (récepteur $RX_SERIAL)" INFO "$LAST_INS" LOG_TIME=$(echo "$LAST_INS" | grep -oP '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') AGE_LOG=$(( $(date +%s) - $(date -d "${LOG_TIME:-0}" +%s 2>/dev/null || echo $(date +%s)) )) [ "$AGE_LOG" -lt 30 ] \ && OK "$RX_PG — dernière insertion il y a ${AGE_LOG}s" \ || WARN "$RX_PG — dernière insertion il y a ${AGE_LOG}s (> 30s — cycle bloqué ?)" else WARN "$RX_PG — aucun log d'insertion trouvé" fi LAST_ERR=$(docker logs "$RX_PG" 2>&1 | grep -i "error\|erreur\|WARNING" | tail -1) [ -n "$LAST_ERR" ] && INFO "$RX_PG — dernier warning/erreur : ${LAST_ERR:0:100}" done # ── 6b. WATCHDOG READSB — résilience (nouveau v3.4) ─────────────────────────── # Vérifie que le garde-fou mis en place suite à l'incident du dongle figé # silencieusement (03/07/2026) est bien actif et fonctionne réellement. HDR "6b. Watchdog readsb — résilience" WATCHDOG_SCRIPT="/data/adsb/scripts/watchdog_readsb.sh" WATCHDOG_LOG="/var/log/readsb_watchdog.log" # Présence du script lui-même if [ -x "$WATCHDOG_SCRIPT" ]; then OK "Script watchdog présent et exécutable ($WATCHDOG_SCRIPT)" else FAIL "Script watchdog absent ou non exécutable ($WATCHDOG_SCRIPT)" fi # Présence dans la crontab (root, puisque docker restart nécessite les droits root) CRON_ACTIVE=$(crontab -l 2>/dev/null | grep "watchdog_readsb.sh" | grep -v '^[[:space:]]*#') CRON_COMMENTED=$(crontab -l 2>/dev/null | grep "watchdog_readsb.sh" | grep '^[[:space:]]*#') if [ -n "$CRON_ACTIVE" ]; then OK "Watchdog actif dans la crontab — $CRON_ACTIVE" elif [ -n "$CRON_COMMENTED" ]; then WARN "Watchdog présent mais COMMENTÉ dans la crontab — désarmé volontairement ? aucune protection automatique tant qu'il n'est pas réactivé : $CRON_COMMENTED" else FAIL "Watchdog ABSENT de la crontab — aucune protection automatique contre un dongle figé" fi # Fraîcheur du state file PAR RÉCEPTEUR (watchdog v3 : un fichier suffixé par # numéro de série, /tmp/readsb_last_msg_count_). Preuve que le cron # s'exécute réellement pour CE récepteur précis, pas juste que le script tourne # globalement — un récepteur ajouté au tableau RECEIVERS après coup sans # relancer le cron une première fois n'aurait pas encore ce fichier. for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" WATCHDOG_STATE="/tmp/readsb_last_msg_count_${RX_SERIAL}" if [ -f "$WATCHDOG_STATE" ]; then STATE_AGE=$(( $(date +%s) - $(stat -c %Y "$WATCHDOG_STATE" 2>/dev/null || echo 0) )) if [ "$STATE_AGE" -lt 600 ]; then OK "Watchdog actif pour $RX_CTN — dernière exécution il y a ${STATE_AGE}s (< 10 min)" else FAIL "Watchdog inactif pour $RX_CTN — dernière exécution il y a ${STATE_AGE}s (> 10 min, cron probablement en échec)" fi else WARN "State file watchdog absent pour $RX_CTN ($WATCHDOG_STATE) — jamais exécuté pour ce récepteur" fi done # Détection de flapping PAR RÉCEPTEUR : redémarrages répétés sur les dernières # 24h (signe d'un problème matériel USB propre à CE dongle plutôt qu'un # blocage isolé) — chaque récepteur est compté séparément pour ne pas noyer # un flapping ciblé dans la moyenne globale. if [ -f "$WATCHDOG_LOG" ]; then for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" RESTARTS_24H=$(awk -v d="$(date -d '24 hours ago' '+%Y-%m-%d %H:%M:%S')" \ '$0 >= d' "$WATCHDOG_LOG" 2>/dev/null | grep -c "redémarrage $RX_CTN") if [ "${RESTARTS_24H:-0}" -eq 0 ]; then OK "$RX_CTN — aucun redémarrage watchdog déclenché sur les dernières 24h" elif [ "${RESTARTS_24H:-0}" -le 2 ]; then WARN "$RX_CTN — $RESTARTS_24H redémarrage(s) watchdog sur les dernières 24h" else FAIL "$RX_CTN — $RESTARTS_24H redémarrages watchdog sur les dernières 24h — flapping probable, vérifier le dongle/câble/hub USB" fi done LAST_WD_LINE=$(tail -1 "$WATCHDOG_LOG" 2>/dev/null) [ -n "$LAST_WD_LINE" ] && INFO "Dernière ligne log watchdog : $LAST_WD_LINE" else WARN "Fichier log watchdog absent ($WATCHDOG_LOG)" fi # RestartCount Docker : détecte un crash-loop même hors périmètre du watchdog # (ex: readsb qui crashe vraiment au lieu de juste se figer) for ctn in "${RX_ALL_CONTAINERS[@]}"; do RCOUNT=$(docker inspect --format '{{.RestartCount}}' "$ctn" 2>/dev/null) if [ -n "$RCOUNT" ]; then [ "${RCOUNT:-0}" -eq 0 ] \ && OK "$ctn : RestartCount=$RCOUNT (aucun crash Docker)" \ || WARN "$ctn : RestartCount=$RCOUNT — le conteneur a redémarré (crash ou OOM), vérifier docker logs $ctn" fi done # Couverture watchdog par récepteur : le script watchdog_readsb.sh ne surveille # QUE le conteneur dont le nom est en dur dans son propre code (docker restart # adsb-readsb) — un 2e récepteur ajouté sans adapter ce script n'a AUCUNE # protection contre un gel silencieux, contrairement à ce que la section # ci-dessus pourrait laisser penser (elle ne teste que l'existence du cron, # pas sa couverture réelle de tous les récepteurs). # Couverture watchdog par récepteur : depuis watchdog_readsb.sh v3, la # surveillance est pilotée par son propre tableau RECEIVERS interne (plus de # nom de conteneur en dur) — on vérifie donc que le numéro de série de chaque # récepteur apparaît bien dans ce tableau, pas un nom de conteneur littéral. for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" if [ -f "$WATCHDOG_SCRIPT" ] && grep -q "detect_dongles.sh" "$WATCHDOG_SCRIPT" 2>/dev/null; then OK "$RX_CTN protégé par le watchdog (v4 — récepteurs dérivés de detect_dongles.sh)" elif [ -f "$WATCHDOG_SCRIPT" ] && grep -qE "\"${RX_SERIAL}:${RX_CTN}:" "$WATCHDOG_SCRIPT" 2>/dev/null; then WARN "$RX_CTN protégé par un watchdog en v3 (tableau RECEIVERS statique) — migrer en v4 pour suivre les dongles réellement présents" else WARN "$RX_CTN NON protégé par le watchdog — absent du tableau RECEIVERS de watchdog_readsb.sh, un gel silencieux ne serait jamais corrigé automatiquement" fi done # Dérive NTP : un décalage d'horloge impacte directement le routage vers les # bonnes partitions (colonne ts) et la clé de dédoublonnage icao+ts+receiver_id NTP_SYNC=$(timedatectl show -p NTPSynchronized --value 2>/dev/null) if [ "$NTP_SYNC" = "yes" ]; then OK "Horloge système synchronisée NTP" elif [ -n "$NTP_SYNC" ]; then FAIL "Horloge système NON synchronisée NTP — risque de dérive impactant ts/partitionnement/dédoublonnage" else INFO "Statut NTP non déterminable (timedatectl indisponible)" fi # ── 7. WEBAPP ───────────────────────────────────────────────────────────────── HDR "7. Webapp — interface utilisateur" for action in live kpi trajectories; do RESP=$(curl -s --max-time 5 \ "http://127.0.0.1:8080/api.php?action=${action}&period=24&granularity=60" 2>/dev/null) if echo "$RESP" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then ERR=$(echo "$RESP" | python3 -c " import json,sys d=json.load(sys.stdin) print(d.get('error',''))" 2>/dev/null) [ -n "$ERR" ] && WARN "api.php?action=${action} : $ERR" \ || OK "api.php?action=${action} : JSON valide" else FAIL "api.php?action=${action} : réponse non-JSON" fi done TAB_CNT=$(docker exec adsb-webapp grep -c 'href="#tab' /var/www/html/index.php 2>/dev/null) OK "${TAB_CNT:-?} onglets dans index.php" [ "${TAB_CNT:-0}" -lt 5 ] 2>/dev/null && WARN "Moins de 5 onglets — index.php peut être obsolète" JS_SZ=$(docker exec adsb-webapp wc -c /var/www/html/js/app.js 2>/dev/null | awk '{print $1}') INFO "app.js : ${JS_SZ:-?} octets" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://${IP}:8080/" 2>/dev/null) [ "$HTTP_CODE" = "200" ] \ && OK "Accès LAN http://${IP}:8080 : HTTP $HTTP_CODE" \ || WARN "Accès LAN http://${IP}:8080 : HTTP ${HTTP_CODE:-?}" # ── 8. AUTOGAIN ─────────────────────────────────────────────────────────────── HDR "8. Autogain" for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" CUR_GAIN=$(docker logs "$RX_CTN" 2>&1 | grep "tuner gain set to" | tail -1 | \ grep -oP '[\d.]+(?= dB)') AG_STATE=$(docker logs "$RX_CTN" 2>&1 | grep "autogain" | \ grep -oP "(?<=state ')[\w]+" | tail -1) [ -n "$CUR_GAIN" ] \ && OK "$RX_CTN — Gain : ${CUR_GAIN} dB — état autogain : ${AG_STATE:-inconnu}" \ || WARN "$RX_CTN — gain courant inconnu" INSUF=$(docker logs "$RX_CTN" 2>&1 | grep -c "Insufficient messages") [ "$INSUF" -gt 3 ] \ && WARN "$RX_CTN — $INSUF cycles avec messages insuffisants (antenne/portée ?)" \ || INFO "$RX_CTN — $INSUF cycle(s) avec messages insuffisants (autogain en cours)" done # ── 9. INFRASTRUCTURE HÔTE ──────────────────────────────────────────────────── HDR "9. Infrastructure hôte" # Docker version et mode DOCKER_VER=$(docker version --format '{{.Server.Version}}' 2>/dev/null) DOCKER_PKG=$(snap list docker 2>/dev/null | awk 'NR==2{print "snap rev."$3}' || echo "package") INFO "Docker ${DOCKER_VER:-?} ($DOCKER_PKG)" # bridge-nf-call-iptables (rappel condensé) BNF=$(cat /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null) [ "${BNF:-1}" = "0" ] \ && OK "bridge-nf-call-iptables=0 — ICC Docker OK" \ || WARN "bridge-nf-call-iptables=${BNF} — peut bloquer la communication inter-conteneurs" # ip_forward IPF=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null) [ "${IPF}" = "1" ] \ && OK "ip_forward=1 — routage inter-réseaux actif" \ || FAIL "ip_forward=${IPF:-0} — routage désactivé" # Espace disque /data et /opt for mnt in /data /opt /var/snap/docker; do if mountpoint -q "$mnt" 2>/dev/null || [ -d "$mnt" ]; then USAGE=$(df -h "$mnt" 2>/dev/null | awk 'NR==2{print $5" utilisé ("$3"/"$2")"}') PCT=$(df "$mnt" 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5); print $5}') [ "${PCT:-0}" -gt 85 ] 2>/dev/null \ && WARN "Disque $mnt : $USAGE" \ || INFO "Disque $mnt : $USAGE" fi done # Uptime système UPTIME_SYS=$(uptime -p 2>/dev/null || uptime | sed 's/.*up /up /' | cut -d',' -f1-2) INFO "Système : $UPTIME_SYS" # Journaux noyau : resets/déconnexions ciblés sur le dongle RTL-SDR uniquement # (v3.4 comptait tout événement usb du bus, y compris souris/clavier USB sans # rapport — corrigé le 05/07/2026 après un faux positif sur un récepteur # Logitech. Le port du dongle est résolu dynamiquement par idVendor:idProduct # car il peut changer si débranché/rebranché sur un autre port physique.) DONGLE_VID_PID="0bda:2838" DONGLE_BUSDEV=$(lsusb | grep -i "$DONGLE_VID_PID" | awk '{print $2, $4}' | tr -d ':' | head -1) DONGLE_BUS=$(echo "$DONGLE_BUSDEV" | awk '{print $1}') DONGLE_DEV=$(echo "$DONGLE_BUSDEV" | awk '{print $2}') if [ -n "$DONGLE_BUS" ] && [ -n "$DONGLE_DEV" ]; then DONGLE_SYSPATH=$(udevadm info -q path -n "/dev/bus/usb/${DONGLE_BUS}/${DONGLE_DEV}" 2>/dev/null) DONGLE_PORT=$(echo "$DONGLE_SYSPATH" | grep -oP '\d+-[\d.]+(?=/|$)' | tail -1) if [ -n "$DONGLE_PORT" ]; then DONGLE_USB_EVENTS=$(dmesg -T 2>/dev/null | grep -E "usb ${DONGLE_PORT}:" | grep -ci "reset\|disconnect") if [ "${DONGLE_USB_EVENTS:-0}" -gt 0 ]; then WARN "$DONGLE_USB_EVENTS événement(s) reset/déconnexion sur le port du dongle RTL-SDR ($DONGLE_PORT) — corrélation possible avec les blocages watchdog" else OK "Aucun reset/déconnexion USB sur le port du dongle RTL-SDR ($DONGLE_PORT) — un blocage ne serait donc pas d'origine matérielle USB" fi INFO "Dongle RTL-SDR localisé : port $DONGLE_PORT (bus $DONGLE_BUS device $DONGLE_DEV)" else WARN "Port USB du dongle non résolu (syspath: ${DONGLE_SYSPATH:-introuvable})" fi else WARN "Dongle RTL-SDR ($DONGLE_VID_PID) non trouvé via lsusb — vérifier connexion physique" fi # Règle udev RTL-SDR if [ -f /etc/udev/rules.d/99-adsb-docker.rules ]; then OK "Règle udev 99-adsb-docker.rules présente (relance auto si USB reconnecté)" else WARN "Règle udev 99-adsb-docker.rules absente — reconnexion USB nécessite intervention manuelle" fi # Service systemd adsb-stack SVC_STATE=$(systemctl is-active adsb-stack.service 2>/dev/null || echo "absent") SVC_DIR=$(systemctl show adsb-stack.service -p WorkingDirectory 2>/dev/null | cut -d= -f2) [ "$SVC_STATE" = "active" ] \ && OK "adsb-stack.service : $SVC_STATE (WorkingDirectory=$SVC_DIR)" \ || INFO "adsb-stack.service : $SVC_STATE" # ── 9b. PARE-FEU ─────────────────────────────────────────────────────────────── HDR "9b. Pare-feu" FIREWALL_TOOL="" command -v ufw &>/dev/null && FIREWALL_TOOL="ufw" command -v firewall-cmd &>/dev/null && FIREWALL_TOOL="firewalld" if [ -z "$FIREWALL_TOOL" ]; then INFO "Aucun pare-feu (ufw/firewalld) installé — aucune restriction possible côté hôte" else case "$FIREWALL_TOOL" in ufw) UFW_ACTIVE=$(ufw status 2>/dev/null | head -1 | grep -qE "^Status:\s*active\s*$" && echo "yes" || echo "no") if [ "$UFW_ACTIVE" = "no" ]; then OK "ufw inactif — pas de restriction sur les ports du stack" else INFO "ufw actif — vérification des ports du stack" FW_PORT_LIST=("8080:webapp") for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" FW_PORT_LIST+=("${RX_PORT}:tar1090 ($RX_CTN)") done for PORT_DESC in "${FW_PORT_LIST[@]}"; do PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC#*:}" if ufw status 2>/dev/null | grep -qE "^${PORT}(/tcp)?[[:space:]]+ALLOW"; then OK "Port $PORT ($DESC) autorisé dans ufw" else FAIL "Port $PORT ($DESC) NON autorisé dans ufw — inaccessible depuis le LAN (ufw allow ${PORT}/tcp)" fi done fi ;; firewalld) FW_STATE=$(firewall-cmd --state 2>/dev/null) if [ "$FW_STATE" != "running" ]; then OK "firewalld inactif — pas de restriction sur les ports du stack" else INFO "firewalld actif — vérification des ports du stack" FW_PORT_LIST=("8080:webapp") for RX in "${RECEIVERS[@]}"; do IFS=':' read -r RX_SERIAL RX_CTN RX_PORT RX_PG <<< "$RX" FW_PORT_LIST+=("${RX_PORT}:tar1090 ($RX_CTN)") done for PORT_DESC in "${FW_PORT_LIST[@]}"; do PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC#*:}" if firewall-cmd --list-ports 2>/dev/null | grep -q "${PORT}/tcp"; then OK "Port $PORT ($DESC) autorisé dans firewalld" else FAIL "Port $PORT ($DESC) NON autorisé dans firewalld — inaccessible depuis le LAN" fi done fi ;; esac fi # Test d'accès réel, indépendant du pare-feu détecté (confirme l'accès effectif # de bout en bout, y compris via des mécanismes non couverts ci-dessus comme # iptables nu, un pare-feu de box/routeur, ou un binding d'interface incorrect) HTTP_WEBAPP_LOCAL=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://127.0.0.1:8080/" 2>/dev/null) [ "$HTTP_WEBAPP_LOCAL" = "200" ] \ && OK "Webapp accessible en local (127.0.0.1:8080) : HTTP $HTTP_WEBAPP_LOCAL" \ || WARN "Webapp non accessible en local (127.0.0.1:8080) : HTTP ${HTTP_WEBAPP_LOCAL:-000}" HTTP_WEBAPP_LAN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://${IP}:8080/" 2>/dev/null) [ "$HTTP_WEBAPP_LAN" = "200" ] \ && OK "Webapp accessible depuis l'IP LAN (${IP}:8080) : HTTP $HTTP_WEBAPP_LAN" \ || FAIL "Webapp INACCESSIBLE depuis l'IP LAN (${IP}:8080) : HTTP ${HTTP_WEBAPP_LAN:-000} — pare-feu, binding réseau, ou routeur à vérifier" # ══════════════════════════════════════════════════════════════════════════════ # RÉSUMÉ TERMINAL # ══════════════════════════════════════════════════════════════════════════════ echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ RÉSUMÉ ║${NC}" echo -e "${BOLD}╠══════════════════════════════════════════════════════╣${NC}" if [ "$ERRORS" -eq 0 ] && [ "$WARNS" -eq 0 ]; then GLOBAL_STATUS="OK"; GLOBAL_COLOR="$GREEN" echo -e "${BOLD}║ ${GREEN}✓ Stack opérationnel — aucune anomalie${NC}${BOLD} ║${NC}" elif [ "$ERRORS" -eq 0 ]; then GLOBAL_STATUS="WARN"; GLOBAL_COLOR="$YELLOW" echo -e "${BOLD}║ ${YELLOW}⚠ Stack opérationnel — $WARNS avertissement(s)${NC}${BOLD} ║${NC}" else GLOBAL_STATUS="ERROR"; GLOBAL_COLOR="$RED" echo -e "${BOLD}║ ${RED}✗ $ERRORS erreur(s), $WARNS avertissement(s)${NC}${BOLD} ║${NC}" fi echo -e "${BOLD}║ Dashboard : http://${IP}:8080${NC}${BOLD} ║${NC}" echo -e "${BOLD}║ tar1090 : http://${IP}:8090${NC}${BOLD} ║${NC}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" # ══════════════════════════════════════════════════════════════════════════════ # GÉNÉRATION HTML # ══════════════════════════════════════════════════════════════════════════════ case "$GLOBAL_STATUS" in OK) BADGE_CLS="badge-ok"; BADGE_TXT="✓ Opérationnel" ;; WARN) BADGE_CLS="badge-warn"; BADGE_TXT="⚠ ${WARNS} avertissement(s)" ;; ERROR) BADGE_CLS="badge-err"; BADGE_TXT="✗ ${ERRORS} erreur(s)" ;; esac cat > "$HTML_OUT" << HTMLEOF ADS-B Linux-25 — Status

✈ ADS-B Linux-25 — Status

Généré le $NOW_DISP — actualisation auto toutes les 60s
${BADGE_TXT}
IP : ${IP}
Erreurs : ${ERRORS}   Avert. : ${WARNS}
/dev/null && echo ok-kpi || echo warn-kpi)">
${AVIONS:-0}
Avions en vue
/dev/null && echo ok-kpi || echo warn-kpi)">
${WITH_POS:-0}
Avec GPS
${AV_TODAY:-0}
Avions aujourd'hui
${SESS_TODAY:-0}
Sessions aujourd'hui
${DB_SIZE:-?}
Base adsb
${CUR_GAIN:-?} dB
Gain SDR
${HTML_ROWS}
ÉtatDétailStatut
HTMLEOF # Copier dans le répertoire source webapp ET dans le conteneur SRC_DIR="/data/adsb/webapp/src" if [ -d "$SRC_DIR" ]; then cp "$HTML_OUT" "$SRC_DIR/status.html" 2>/dev/null fi docker cp "$HTML_OUT" adsb-webapp:/var/www/html/status.html 2>/dev/null && \ echo -e "\n ${GREEN}→${NC} Rapport HTML : http://${IP}:8080/status.html" || \ echo -e "\n ${YELLOW}⚠${NC} Copie Docker échouée — fichier local : $HTML_OUT" exit $ERRORS