From 732d8be9b8d2c5ccf09f55b7608638d1c5672cc6 Mon Sep 17 00:00:00 2001 From: azur_test3 Date: Mon, 10 Aug 2026 02:26:10 +0200 Subject: [PATCH] Ajout du code initial ADS-B --- .gitignore | 20 + adsb-stack.service | 33 + adsb2pg/Dockerfile | 7 + adsb2pg/adsb2pg.py | Bin 0 -> 36181 bytes adsb2pg/requirements.txt | 2 + apply_tuning.sh | 97 + check_adsb.sh | 1332 +++++++ check_adsb_claude_ok_v2.sh | 399 ++ check_adsb_claude_v1.sh | 304 ++ check_adsb_mistral.sh | 284 ++ check_adsb_mistral_v0.sh | 88 + check_adsb_mistral_v2.sh | 309 ++ check_adsb_mistral_v3.sh | 322 ++ check_adsb_mistral_v4.sh | 347 ++ check_adsb_mistral_v5.sh | 350 ++ check_adsb_mistral_v5_ByClaude.sh | 281 ++ check_adsb_old.sh | 82 + docker-compose.yml | 266 ++ export_adsb_bundle.sh | 182 + fix_permissions.sh | 78 + install_adsb_stack.sh | 1049 +++++ migrate_to_postgis.sh | 90 + monitor_postgres_tables.sh | 283 ++ postgres/conf | 1 + postgres/logs | 1 + queries.sql | 65 + scripts/detect_dongles.sh | 53 + scripts/fetch_flags.sh | 53 + scripts/gen_env.sh | 74 + scripts/watchdog_readsb.sh | 198 + sql/01_audit_partitions.sql | 63 + sql/02_convert_partitions_vides.sql | 98 + sql/03_convert_partition_avec_donnees.sql | 141 + sql/04_airports.sql | 88 + sql/05_airports_zones.sql | 76 + start.sh | 20 + update-adsb.sh | 518 +++ update-webapp.sh | 263 ++ watchlist_schema.sql | 71 + webapp/Dockerfile | 71 + webapp/src/.htaccess | 18 + webapp/src/api.php | 1914 +++++++++ webapp/src/assets/flags/ad.png | Bin 0 -> 644 bytes webapp/src/assets/flags/ae.png | Bin 0 -> 677 bytes webapp/src/assets/flags/af.png | Bin 0 -> 631 bytes webapp/src/assets/flags/al.png | Bin 0 -> 551 bytes webapp/src/assets/flags/am.png | Bin 0 -> 601 bytes webapp/src/assets/flags/ar.png | Bin 0 -> 593 bytes webapp/src/assets/flags/at.png | Bin 0 -> 613 bytes webapp/src/assets/flags/au.png | Bin 0 -> 648 bytes webapp/src/assets/flags/aw.png | Bin 0 -> 696 bytes webapp/src/assets/flags/az.png | Bin 0 -> 673 bytes webapp/src/assets/flags/ba.png | Bin 0 -> 734 bytes webapp/src/assets/flags/bd.png | Bin 0 -> 692 bytes webapp/src/assets/flags/be.png | Bin 0 -> 544 bytes webapp/src/assets/flags/bg.png | Bin 0 -> 561 bytes webapp/src/assets/flags/bh.png | Bin 0 -> 662 bytes webapp/src/assets/flags/bm.png | Bin 0 -> 751 bytes webapp/src/assets/flags/br.png | Bin 0 -> 727 bytes webapp/src/assets/flags/bs.png | Bin 0 -> 609 bytes webapp/src/assets/flags/by.png | Bin 0 -> 621 bytes webapp/src/assets/flags/ca.png | Bin 0 -> 606 bytes webapp/src/assets/flags/ch.png | Bin 0 -> 436 bytes webapp/src/assets/flags/ci.png | Bin 0 -> 636 bytes webapp/src/assets/flags/cl.png | Bin 0 -> 670 bytes webapp/src/assets/flags/cm.png | Bin 0 -> 590 bytes webapp/src/assets/flags/cn.png | Bin 0 -> 653 bytes webapp/src/assets/flags/co.png | Bin 0 -> 569 bytes webapp/src/assets/flags/cr.png | Bin 0 -> 685 bytes webapp/src/assets/flags/cu.png | Bin 0 -> 720 bytes webapp/src/assets/flags/cy.png | Bin 0 -> 620 bytes webapp/src/assets/flags/cz.png | Bin 0 -> 679 bytes webapp/src/assets/flags/de.png | Bin 0 -> 576 bytes webapp/src/assets/flags/dk.png | Bin 0 -> 675 bytes webapp/src/assets/flags/do.png | Bin 0 -> 618 bytes webapp/src/assets/flags/dz.png | Bin 0 -> 731 bytes webapp/src/assets/flags/ee.png | Bin 0 -> 647 bytes webapp/src/assets/flags/eg.png | Bin 0 -> 660 bytes webapp/src/assets/flags/es.png | Bin 0 -> 675 bytes webapp/src/assets/flags/et.png | Bin 0 -> 769 bytes webapp/src/assets/flags/fi.png | Bin 0 -> 663 bytes webapp/src/assets/flags/fr.png | Bin 0 -> 643 bytes webapp/src/assets/flags/ga.png | Bin 0 -> 709 bytes webapp/src/assets/flags/gb.png | Bin 0 -> 815 bytes webapp/src/assets/flags/ge.png | Bin 0 -> 701 bytes webapp/src/assets/flags/gg.png | Bin 0 -> 741 bytes webapp/src/assets/flags/gh.png | Bin 0 -> 633 bytes webapp/src/assets/flags/gr.png | Bin 0 -> 713 bytes webapp/src/assets/flags/hk.png | Bin 0 -> 501 bytes webapp/src/assets/flags/hr.png | Bin 0 -> 658 bytes webapp/src/assets/flags/hu.png | Bin 0 -> 560 bytes webapp/src/assets/flags/id.png | Bin 0 -> 533 bytes webapp/src/assets/flags/ie.png | Bin 0 -> 693 bytes webapp/src/assets/flags/il.png | Bin 0 -> 644 bytes webapp/src/assets/flags/im.png | Bin 0 -> 536 bytes webapp/src/assets/flags/in.png | Bin 0 -> 673 bytes webapp/src/assets/flags/iq.png | Bin 0 -> 694 bytes webapp/src/assets/flags/ir.png | Bin 0 -> 685 bytes webapp/src/assets/flags/is.png | Bin 0 -> 751 bytes webapp/src/assets/flags/it.png | Bin 0 -> 493 bytes webapp/src/assets/flags/je.png | Bin 0 -> 840 bytes webapp/src/assets/flags/jm.png | Bin 0 -> 694 bytes webapp/src/assets/flags/jo.png | Bin 0 -> 708 bytes webapp/src/assets/flags/jp.png | Bin 0 -> 562 bytes webapp/src/assets/flags/ke.png | Bin 0 -> 870 bytes webapp/src/assets/flags/kh.png | Bin 0 -> 670 bytes webapp/src/assets/flags/kr.png | Bin 0 -> 713 bytes webapp/src/assets/flags/kw.png | Bin 0 -> 726 bytes webapp/src/assets/flags/ky.png | Bin 0 -> 736 bytes webapp/src/assets/flags/kz.png | Bin 0 -> 624 bytes webapp/src/assets/flags/la.png | Bin 0 -> 551 bytes webapp/src/assets/flags/lb.png | Bin 0 -> 622 bytes webapp/src/assets/flags/li.png | Bin 0 -> 566 bytes webapp/src/assets/flags/lk.png | Bin 0 -> 757 bytes webapp/src/assets/flags/lt.png | Bin 0 -> 592 bytes webapp/src/assets/flags/lu.png | Bin 0 -> 644 bytes webapp/src/assets/flags/lv.png | Bin 0 -> 521 bytes webapp/src/assets/flags/ly.png | Bin 0 -> 614 bytes webapp/src/assets/flags/ma.png | Bin 0 -> 469 bytes webapp/src/assets/flags/mc.png | Bin 0 -> 584 bytes webapp/src/assets/flags/md.png | Bin 0 -> 612 bytes webapp/src/assets/flags/me.png | Bin 0 -> 692 bytes webapp/src/assets/flags/mk.png | Bin 0 -> 782 bytes webapp/src/assets/flags/mm.png | Bin 0 -> 709 bytes webapp/src/assets/flags/mn.png | Bin 0 -> 583 bytes webapp/src/assets/flags/mo.png | Bin 0 -> 556 bytes webapp/src/assets/flags/mt.png | Bin 0 -> 560 bytes webapp/src/assets/flags/mx.png | Bin 0 -> 618 bytes webapp/src/assets/flags/my.png | Bin 0 -> 845 bytes webapp/src/assets/flags/ng.png | Bin 0 -> 601 bytes webapp/src/assets/flags/nl.png | Bin 0 -> 530 bytes webapp/src/assets/flags/no.png | Bin 0 -> 670 bytes webapp/src/assets/flags/np.png | Bin 0 -> 612 bytes webapp/src/assets/flags/nz.png | Bin 0 -> 653 bytes webapp/src/assets/flags/om.png | Bin 0 -> 624 bytes webapp/src/assets/flags/pa.png | Bin 0 -> 692 bytes webapp/src/assets/flags/pe.png | Bin 0 -> 545 bytes webapp/src/assets/flags/ph.png | Bin 0 -> 671 bytes webapp/src/assets/flags/pk.png | Bin 0 -> 647 bytes webapp/src/assets/flags/pl.png | Bin 0 -> 569 bytes webapp/src/assets/flags/pt.png | Bin 0 -> 693 bytes webapp/src/assets/flags/qa.png | Bin 0 -> 549 bytes webapp/src/assets/flags/ro.png | Bin 0 -> 515 bytes webapp/src/assets/flags/rs.png | Bin 0 -> 786 bytes webapp/src/assets/flags/ru.png | Bin 0 -> 560 bytes webapp/src/assets/flags/sa.png | Bin 0 -> 650 bytes webapp/src/assets/flags/se.png | Bin 0 -> 657 bytes webapp/src/assets/flags/sg.png | Bin 0 -> 649 bytes webapp/src/assets/flags/si.png | Bin 0 -> 720 bytes webapp/src/assets/flags/sk.png | Bin 0 -> 674 bytes webapp/src/assets/flags/sm.png | Bin 0 -> 907 bytes webapp/src/assets/flags/sn.png | Bin 0 -> 572 bytes webapp/src/assets/flags/sy.png | Bin 0 -> 760 bytes webapp/src/assets/flags/th.png | Bin 0 -> 579 bytes webapp/src/assets/flags/tn.png | Bin 0 -> 616 bytes webapp/src/assets/flags/tr.png | Bin 0 -> 618 bytes webapp/src/assets/flags/tw.png | Bin 0 -> 547 bytes webapp/src/assets/flags/ua.png | Bin 0 -> 664 bytes webapp/src/assets/flags/us.png | Bin 0 -> 890 bytes webapp/src/assets/flags/uz.png | Bin 0 -> 678 bytes webapp/src/assets/flags/ve.png | Bin 0 -> 634 bytes webapp/src/assets/flags/vn.png | Bin 0 -> 473 bytes webapp/src/assets/flags/za.png | Bin 0 -> 825 bytes webapp/src/config.php | 48 + webapp/src/css/app.css | 424 ++ webapp/src/deploiement.sh | 34 + webapp/src/index.php | 1681 ++++++++ webapp/src/js/app.js | 4376 +++++++++++++++++++++ webapp/src/status.html | 124 + webapp/src/watchlist_schema.sql | 71 + zones_gouvernementales.sql | 51 + 171 files changed, 16800 insertions(+) create mode 100644 .gitignore create mode 100644 adsb-stack.service create mode 100644 adsb2pg/Dockerfile create mode 100644 adsb2pg/adsb2pg.py create mode 100644 adsb2pg/requirements.txt create mode 100755 apply_tuning.sh create mode 100755 check_adsb.sh create mode 100755 check_adsb_claude_ok_v2.sh create mode 100755 check_adsb_claude_v1.sh create mode 100755 check_adsb_mistral.sh create mode 100755 check_adsb_mistral_v0.sh create mode 100755 check_adsb_mistral_v2.sh create mode 100755 check_adsb_mistral_v3.sh create mode 100755 check_adsb_mistral_v4.sh create mode 100755 check_adsb_mistral_v5.sh create mode 100755 check_adsb_mistral_v5_ByClaude.sh create mode 100755 check_adsb_old.sh create mode 100644 docker-compose.yml create mode 100755 export_adsb_bundle.sh create mode 100755 fix_permissions.sh create mode 100755 install_adsb_stack.sh create mode 100755 migrate_to_postgis.sh create mode 100755 monitor_postgres_tables.sh create mode 120000 postgres/conf create mode 120000 postgres/logs create mode 100644 queries.sql create mode 100755 scripts/detect_dongles.sh create mode 100755 scripts/fetch_flags.sh create mode 100755 scripts/gen_env.sh create mode 100755 scripts/watchdog_readsb.sh create mode 100644 sql/01_audit_partitions.sql create mode 100644 sql/02_convert_partitions_vides.sql create mode 100644 sql/03_convert_partition_avec_donnees.sql create mode 100644 sql/04_airports.sql create mode 100644 sql/05_airports_zones.sql create mode 100755 start.sh create mode 100755 update-adsb.sh create mode 100755 update-webapp.sh create mode 100644 watchlist_schema.sql create mode 100644 webapp/Dockerfile create mode 100644 webapp/src/.htaccess create mode 100644 webapp/src/api.php create mode 100644 webapp/src/assets/flags/ad.png create mode 100644 webapp/src/assets/flags/ae.png create mode 100644 webapp/src/assets/flags/af.png create mode 100644 webapp/src/assets/flags/al.png create mode 100644 webapp/src/assets/flags/am.png create mode 100644 webapp/src/assets/flags/ar.png create mode 100644 webapp/src/assets/flags/at.png create mode 100644 webapp/src/assets/flags/au.png create mode 100644 webapp/src/assets/flags/aw.png create mode 100644 webapp/src/assets/flags/az.png create mode 100644 webapp/src/assets/flags/ba.png create mode 100644 webapp/src/assets/flags/bd.png create mode 100644 webapp/src/assets/flags/be.png create mode 100644 webapp/src/assets/flags/bg.png create mode 100644 webapp/src/assets/flags/bh.png create mode 100644 webapp/src/assets/flags/bm.png create mode 100644 webapp/src/assets/flags/br.png create mode 100644 webapp/src/assets/flags/bs.png create mode 100644 webapp/src/assets/flags/by.png create mode 100644 webapp/src/assets/flags/ca.png create mode 100644 webapp/src/assets/flags/ch.png create mode 100644 webapp/src/assets/flags/ci.png create mode 100644 webapp/src/assets/flags/cl.png create mode 100644 webapp/src/assets/flags/cm.png create mode 100644 webapp/src/assets/flags/cn.png create mode 100644 webapp/src/assets/flags/co.png create mode 100644 webapp/src/assets/flags/cr.png create mode 100644 webapp/src/assets/flags/cu.png create mode 100644 webapp/src/assets/flags/cy.png create mode 100644 webapp/src/assets/flags/cz.png create mode 100644 webapp/src/assets/flags/de.png create mode 100644 webapp/src/assets/flags/dk.png create mode 100644 webapp/src/assets/flags/do.png create mode 100644 webapp/src/assets/flags/dz.png create mode 100644 webapp/src/assets/flags/ee.png create mode 100644 webapp/src/assets/flags/eg.png create mode 100644 webapp/src/assets/flags/es.png create mode 100644 webapp/src/assets/flags/et.png create mode 100644 webapp/src/assets/flags/fi.png create mode 100644 webapp/src/assets/flags/fr.png create mode 100644 webapp/src/assets/flags/ga.png create mode 100644 webapp/src/assets/flags/gb.png create mode 100644 webapp/src/assets/flags/ge.png create mode 100644 webapp/src/assets/flags/gg.png create mode 100644 webapp/src/assets/flags/gh.png create mode 100644 webapp/src/assets/flags/gr.png create mode 100644 webapp/src/assets/flags/hk.png create mode 100644 webapp/src/assets/flags/hr.png create mode 100644 webapp/src/assets/flags/hu.png create mode 100644 webapp/src/assets/flags/id.png create mode 100644 webapp/src/assets/flags/ie.png create mode 100644 webapp/src/assets/flags/il.png create mode 100644 webapp/src/assets/flags/im.png create mode 100644 webapp/src/assets/flags/in.png create mode 100644 webapp/src/assets/flags/iq.png create mode 100644 webapp/src/assets/flags/ir.png create mode 100644 webapp/src/assets/flags/is.png create mode 100644 webapp/src/assets/flags/it.png create mode 100644 webapp/src/assets/flags/je.png create mode 100644 webapp/src/assets/flags/jm.png create mode 100644 webapp/src/assets/flags/jo.png create mode 100644 webapp/src/assets/flags/jp.png create mode 100644 webapp/src/assets/flags/ke.png create mode 100644 webapp/src/assets/flags/kh.png create mode 100644 webapp/src/assets/flags/kr.png create mode 100644 webapp/src/assets/flags/kw.png create mode 100644 webapp/src/assets/flags/ky.png create mode 100644 webapp/src/assets/flags/kz.png create mode 100644 webapp/src/assets/flags/la.png create mode 100644 webapp/src/assets/flags/lb.png create mode 100644 webapp/src/assets/flags/li.png create mode 100644 webapp/src/assets/flags/lk.png create mode 100644 webapp/src/assets/flags/lt.png create mode 100644 webapp/src/assets/flags/lu.png create mode 100644 webapp/src/assets/flags/lv.png create mode 100644 webapp/src/assets/flags/ly.png create mode 100644 webapp/src/assets/flags/ma.png create mode 100644 webapp/src/assets/flags/mc.png create mode 100644 webapp/src/assets/flags/md.png create mode 100644 webapp/src/assets/flags/me.png create mode 100644 webapp/src/assets/flags/mk.png create mode 100644 webapp/src/assets/flags/mm.png create mode 100644 webapp/src/assets/flags/mn.png create mode 100644 webapp/src/assets/flags/mo.png create mode 100644 webapp/src/assets/flags/mt.png create mode 100644 webapp/src/assets/flags/mx.png create mode 100644 webapp/src/assets/flags/my.png create mode 100644 webapp/src/assets/flags/ng.png create mode 100644 webapp/src/assets/flags/nl.png create mode 100644 webapp/src/assets/flags/no.png create mode 100644 webapp/src/assets/flags/np.png create mode 100644 webapp/src/assets/flags/nz.png create mode 100644 webapp/src/assets/flags/om.png create mode 100644 webapp/src/assets/flags/pa.png create mode 100644 webapp/src/assets/flags/pe.png create mode 100644 webapp/src/assets/flags/ph.png create mode 100644 webapp/src/assets/flags/pk.png create mode 100644 webapp/src/assets/flags/pl.png create mode 100644 webapp/src/assets/flags/pt.png create mode 100644 webapp/src/assets/flags/qa.png create mode 100644 webapp/src/assets/flags/ro.png create mode 100644 webapp/src/assets/flags/rs.png create mode 100644 webapp/src/assets/flags/ru.png create mode 100644 webapp/src/assets/flags/sa.png create mode 100644 webapp/src/assets/flags/se.png create mode 100644 webapp/src/assets/flags/sg.png create mode 100644 webapp/src/assets/flags/si.png create mode 100644 webapp/src/assets/flags/sk.png create mode 100644 webapp/src/assets/flags/sm.png create mode 100644 webapp/src/assets/flags/sn.png create mode 100644 webapp/src/assets/flags/sy.png create mode 100644 webapp/src/assets/flags/th.png create mode 100644 webapp/src/assets/flags/tn.png create mode 100644 webapp/src/assets/flags/tr.png create mode 100644 webapp/src/assets/flags/tw.png create mode 100644 webapp/src/assets/flags/ua.png create mode 100644 webapp/src/assets/flags/us.png create mode 100644 webapp/src/assets/flags/uz.png create mode 100644 webapp/src/assets/flags/ve.png create mode 100644 webapp/src/assets/flags/vn.png create mode 100644 webapp/src/assets/flags/za.png create mode 100644 webapp/src/config.php create mode 100644 webapp/src/css/app.css create mode 100755 webapp/src/deploiement.sh create mode 100644 webapp/src/index.php create mode 100644 webapp/src/js/app.js create mode 100644 webapp/src/status.html create mode 100644 webapp/src/watchlist_schema.sql create mode 100644 zones_gouvernementales.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a2b738 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Secrets - JAMAIS committer +.env +.env.* +!.env.example + +# Sauvegardes et fichiers temporaires +*.bak +*.bak_* +*.bak-* +backups/ + +# Logs +logs/ +*.log +tar1090-db/ +adsb2pg/tar1090-db/ +/v +webapp/v +docker-compose*.bak* +docker-compose_*_DoNotWork.yml diff --git a/adsb-stack.service b/adsb-stack.service new file mode 100644 index 0000000..e5333f3 --- /dev/null +++ b/adsb-stack.service @@ -0,0 +1,33 @@ +[Unit] +Description=Stack ADS-B (readsb + tar1090 + adsb2pg) +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/data/adsb + +# Laisse le bus USB s'énumérer avant de détecter les dongles +ExecStartPre=/bin/sleep 15 + +# Recalcule .env.receivers (RX_SERIAL + COMPOSE_PROFILES) depuis le +# matériel réellement présent. C'est ce qui rend le stack adaptatif : +# 1, 2 ou 3 dongles démarrent les profiles rx1..rx3 correspondants, et un +# dongle débranché ne laisse pas tourner un readsb à vide. +ExecStartPre=/data/adsb/scripts/gen_env.sh + +# Les deux --env-file sont nécessaires : +# .env → PG_PASSWORD (secret, non versionné) +# .env.receivers → COMPOSE_PROFILES et RX_SERIAL (généré) +# Dès qu'on passe --env-file, Compose cesse de charger .env automatiquement, +# d'où l'obligation de le déclarer explicitement lui aussi. +ExecStart=/usr/bin/docker compose --env-file /data/adsb/.env --env-file /data/adsb/.env.receivers up -d --remove-orphans + +ExecStop=/usr/bin/docker compose --env-file /data/adsb/.env --env-file /data/adsb/.env.receivers down + +TimeoutStartSec=180 + +[Install] +WantedBy=multi-user.target diff --git a/adsb2pg/Dockerfile b/adsb2pg/Dockerfile new file mode 100644 index 0000000..ff77ed1 --- /dev/null +++ b/adsb2pg/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY adsb2pg.py . +COPY tar1090-db /tar1090-db +CMD ["python", "-u", "adsb2pg.py"] diff --git a/adsb2pg/adsb2pg.py b/adsb2pg/adsb2pg.py new file mode 100644 index 0000000000000000000000000000000000000000..88931612087c6153e726c35facfe90e72c11cf2f GIT binary patch literal 36181 zcmeIuF#!Mo0K%a4Pi+Wah(KY$fB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd Y0RsjM7%*VKfB^#r3>YwAz<_~cV2xD(0RR91 literal 0 HcmV?d00001 diff --git a/adsb2pg/requirements.txt b/adsb2pg/requirements.txt new file mode 100644 index 0000000..d74eaba --- /dev/null +++ b/adsb2pg/requirements.txt @@ -0,0 +1,2 @@ +requests==2.32.3 +psycopg2-binary==2.9.9 diff --git a/apply_tuning.sh b/apply_tuning.sh new file mode 100755 index 0000000..5eb8eca --- /dev/null +++ b/apply_tuning.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# ============================================================================ +# Application du tuning postgresql.conf -- Stack ADS-B Linux-25 +# Hardware : i7-7567U, 4 threads logiques, 16 Go RAM, SSD Samsung +# Conteneur : wordpress-postgres (postgis/postgis:16-3.5) +# Fichier de config : volume pg-conf-std, monté /etc/postgresql +# ============================================================================ +set -euo pipefail + +CONTAINER="wordpress-postgres" +CONF_PATH_IN_CONTAINER="/etc/postgresql/postgresql.conf" +BACKUP_DIR="/backup/postgres_tuning_$(date +%Y%m%d_%H%M%S)" + +echo "==> 1. Sauvegarde du postgresql.conf actuel" +mkdir -p "$BACKUP_DIR" +docker cp "${CONTAINER}:${CONF_PATH_IN_CONTAINER}" "$BACKUP_DIR/postgresql.conf.bak" +echo " Sauvegardé : $BACKUP_DIR/postgresql.conf.bak" + +echo "==> 2. Application des nouveaux paramètres" +# On utilise sed dans le conteneur pour modifier les lignes existantes, +# et on ajoute les nouvelles directives en fin de fichier (section CUSTOMIZED +# OPTIONS déjà présente, cohérent avec les ajouts précédents JCA 2026-06-29) + +docker exec "$CONTAINER" bash -c "cat >> ${CONF_PATH_IN_CONTAINER} << 'EOF' + +# ── Tuning mémoire/IO/planner (Claude, $(date +%Y-%m-%d)) ── +# Hardware : i7-7567U 4 threads logiques, 16 Go RAM, SSD Samsung +# Remplace les valeurs précédentes (shared_buffers=512MB, work_mem=256MB) +# par des valeurs adaptées à la RAM disponible et au risque d'OOM. +shared_buffers = 3200MB +effective_cache_size = 9GB +work_mem = 32MB +maintenance_work_mem = 512MB +max_wal_size = 4GB +min_wal_size = 1GB +checkpoint_completion_target = 0.9 +random_page_cost = 1.1 +effective_io_concurrency = 150 +max_parallel_workers_per_gather = 2 +autovacuum_analyze_scale_factor = 0.05 +autovacuum_vacuum_cost_delay = 2ms +EOF" + +echo "==> 3. Vérification de la syntaxe (parse à blanc)" +# postgres --check-config n'existe pas nativement ; on vérifie via un restart +# contrôlé avec rollback possible si échec. + +echo "==> 4. Redémarrage du conteneur (shared_buffers nécessite un restart complet)" +cd /root/wordpress +docker compose restart postgres + +echo "==> 5. Attente du démarrage" +for i in $(seq 1 30); do + if docker exec "$CONTAINER" pg_isready -U pgz_admin -d wpz_postgres > /dev/null 2>&1; then + echo " PostgreSQL prêt après ${i}s" + break + fi + if [ "$i" -eq 30 ]; then + echo " ÉCHEC : PostgreSQL ne démarre pas avec la nouvelle config." + echo " Rollback automatique..." + docker cp "$BACKUP_DIR/postgresql.conf.bak" "${CONTAINER}:${CONF_PATH_IN_CONTAINER}" + docker compose restart postgres + echo " Rollback effectué. Vérifier les logs : docker logs ${CONTAINER} --tail 50" + exit 1 + fi + sleep 1 +done + +echo "==> 6. Vérification des paramètres effectivement appliqués" +docker exec "$CONTAINER" psql -U pgz_admin -d wpz_postgres -c " +SELECT name, setting, unit +FROM pg_settings +WHERE name IN ( + 'shared_buffers', 'effective_cache_size', 'work_mem', 'maintenance_work_mem', + 'max_wal_size', 'min_wal_size', 'random_page_cost', 'effective_io_concurrency', + 'max_parallel_workers_per_gather' +) +ORDER BY name; +" + +echo "==> 7. Vérification que les données et services sont intacts" +docker exec "$CONTAINER" psql -U pgz_admin -d wpz_postgres -c \ + "SELECT count(*) AS partitions FROM pg_inherits WHERE inhparent = 'adsb.positions'::regclass;" + +echo "" +echo "Si adsb2pg/webapp tournaient déjà, ils n'ont pas besoin d'être redémarrés" +echo "(ils se reconnectent automatiquement à PostgreSQL). Vérification :" +docker exec "$CONTAINER" psql -U pgz_admin -d wpz_postgres -c \ + "SELECT max(ts) AS derniere_insertion FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" + +echo "" +echo "============================================================" +echo "Sauvegarde de l'ancienne config : $BACKUP_DIR/postgresql.conf.bak" +echo "Rollback manuel si besoin plus tard :" +echo " docker cp $BACKUP_DIR/postgresql.conf.bak ${CONTAINER}:${CONF_PATH_IN_CONTAINER}" +echo " cd /root/wordpress && docker compose restart postgres" +echo "============================================================" diff --git a/check_adsb.sh b/check_adsb.sh new file mode 100755 index 0000000..02945ce --- /dev/null +++ b/check_adsb.sh @@ -0,0 +1,1332 @@ +#!/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 diff --git a/check_adsb_claude_ok_v2.sh b/check_adsb_claude_ok_v2.sh new file mode 100755 index 0000000..f86fe91 --- /dev/null +++ b/check_adsb_claude_ok_v2.sh @@ -0,0 +1,399 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# check_adsb.sh — Vérification complète du stack ADS-B Linux-25 +# Version 3.0 — JCA 2026 — sortie terminal + HTML +# ═══════════════════════════════════════════════════════════════════════════════ + +HTML_OUT="${1:-/var/www/html/status.html}" # chemin HTML (modifiable en arg) + +# ── 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" +for ctn in adsb-readsb adsb-tar1090 adsb-adsb2pg adsb-webapp wordpress-postgres; 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 RTL-SDR ───────────────────────────────────────────────────────── +HDR "2. Dongle RTL-SDR" +if docker logs adsb-readsb 2>&1 | grep -q "SN 00000010"; then + GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 | grep -oP '[\d.]+(?= dB)') + OK "AirNav FlightStick SN:00000010 détecté — gain ${GAIN:-?} dB" +else + FAIL "Dongle SN:00000010 non détecté dans les logs readsb" +fi +ls /dev/adsb_dongle 2>/dev/null && OK "Symlink /dev/adsb_dongle présent" \ + || WARN "Symlink /dev/adsb_dongle absent" + +# ── 3. DONNÉES ADS-B ────────────────────────────────────────────────────────── +HDR "3. Données ADS-B" +AC_JSON=$(curl -s --max-time 5 "http://127.0.0.1:8090/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 "tar1090 actif — $(printf '%s' "$MSGS" | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta') messages décodés" \ + || WARN "tar1090 répond — 0 message (démarrage récent ?)" + OK "$AVIONS avion(s) en vue dont $WITH_POS avec position GPS" +else + FAIL "tar1090 ne répond pas sur :8090/data/aircraft.json" +fi + +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" +declare -A PORT_LABELS=( + ["8080"]="webapp PHP" ["8090"]="tar1090 nginx" + ["30005"]="Beast TCP" ["5432"]="PostgreSQL" + ["30002"]="RAW TCP" ["30003"]="SBS TCP" +) +for port in 8080 8090 30005 5432 30002 30003; do + ss -tlnp 2>/dev/null | grep -q ":${port}[[:space:]]" \ + && OK "Port $port en écoute — ${PORT_LABELS[$port]}" \ + || FAIL "Port $port absent — ${PORT_LABELS[$port]}" +done + +READSB_IP=$(docker inspect adsb-readsb 2>/dev/null | python3 -c " +import json,sys +d=json.load(sys.stdin)[0] +for n,v in d['NetworkSettings']['Networks'].items(): + if 'adsb' in n: print(v['IPAddress'])" 2>/dev/null) +[ -n "$READSB_IP" ] && [ "$READSB_IP" != "None" ] \ + && OK "readsb attaché à adsb-net : $READSB_IP" \ + || FAIL "readsb non attaché à adsb-net (Beast TCP impossible)" + +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" + +# ── 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) +OK "${PART_POS:-0} sous-partitions adsb.positions" + +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" + +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:-?}" + +# ── 6. ADSB2PG ──────────────────────────────────────────────────────────────── +HDR "6. adsb2pg — collecteur" +LAST_INS=$(docker logs adsb-adsb2pg 2>&1 | grep "positions insérées" | tail -1) +if [ -n "$LAST_INS" ]; then + OK "adsb2pg actif" + 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 "Dernière insertion il y a ${AGE_LOG}s" \ + || WARN "Dernière insertion il y a ${AGE_LOG}s (> 30s — cycle bloqué ?)" +else + WARN "Aucun log d'insertion trouvé" +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" +CUR_GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 | \ + grep -oP '[\d.]+(?= dB)') +AG_STATE=$(docker logs adsb-readsb 2>&1 | grep "autogain" | \ + grep -oP "(?<=state ')[\w]+" | tail -1) +[ -n "$CUR_GAIN" ] \ + && OK "Gain : ${CUR_GAIN} dB — état autogain : ${AG_STATE:-inconnu}" \ + || WARN "Gain courant inconnu" + +INSUF=$(docker logs adsb-readsb 2>&1 | grep -c "Insufficient messages") +[ "$INSUF" -gt 3 ] \ + && WARN "$INSUF cycles avec messages insuffisants (antenne/portée ?)" \ + || INFO "$INSUF cycle(s) avec messages insuffisants (autogain en cours)" + +# ══════════════════════════════════════════════════════════════════════════════ +# 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 conteneur webapp +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 + diff --git a/check_adsb_claude_v1.sh b/check_adsb_claude_v1.sh new file mode 100755 index 0000000..e065e84 --- /dev/null +++ b/check_adsb_claude_v1.sh @@ -0,0 +1,304 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# check_adsb.sh — Vérification complète du stack ADS-B Linux-25 +# Version 2.0 — JCA 2026 +# ═══════════════════════════════════════════════════════════════════════════════ + +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' + +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}"; } + +ERRORS=0; WARNS=0 +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}') + +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Stack ADS-B Linux-25 — Vérification complète ║${NC}" +echo -e "${BOLD}║ $(date '+%a %d/%m/%Y %H:%M:%S') ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" +info "IP détectée : ${IP:-inconnue}" + +# ── 1. CONTENEURS ───────────────────────────────────────────────────────────── +hdr "1. Conteneurs Docker" +CONTAINERS="adsb-readsb adsb-tar1090 adsb-adsb2pg adsb-webapp wordpress-postgres" +for ctn in $CONTAINERS; do + STATUS=$(docker inspect --format '{{.State.Status}}' "$ctn" 2>/dev/null) + HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$ctn" 2>/dev/null) + if [ "$STATUS" = "running" ]; then + UPTIME=$(docker inspect --format '{{.State.StartedAt}}' "$ctn" | xargs -I{} date -d {} '+%d/%m %H:%M' 2>/dev/null) + LABEL="${ctn}" + [ -n "$HEALTH" ] && [ "$HEALTH" != "" ] && LABEL="$LABEL [$HEALTH]" + if [ "$HEALTH" = "unhealthy" ]; then + warn "$LABEL — démarré $UPTIME" + else + ok "$LABEL — démarré $UPTIME" + fi + else + fail "$ctn — STATUS=$STATUS" + fi +done + +# ── 2. DONGLE RTL-SDR ───────────────────────────────────────────────────────── +hdr "2. Dongle RTL-SDR" +if docker logs adsb-readsb 2>&1 | grep -q "SN 00000010"; then + ok "AirNav FlightStick SN:00000010 détecté" + GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 | grep -oP '[\d.]+(?= dB)') + [ -n "$GAIN" ] && info "Gain actuel : ${GAIN} dB (autogain)" +else + fail "Dongle SN:00000010 non détecté dans les logs" +fi + +# Vérifier le device USB sur l'hôte +if ls /dev/adsb_dongle 2>/dev/null | grep -q adsb_dongle; then + ok "Symlink /dev/adsb_dongle présent" +elif lsusb 2>/dev/null | grep -q "RTL2838\|2838:0001\|0bda:2838"; then + ok "Dongle détecté via lsusb (symlink absent)" +else + warn "Symlink /dev/adsb_dongle absent" +fi + +# ── 3. DONNÉES ADS-B ────────────────────────────────────────────────────────── +hdr "3. Données ADS-B" +# Vérifier aircraft.json via tar1090 +AC_JSON=$(curl -s --max-time 5 "http://127.0.0.1:8090/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) +n=sum(1 for a in d.get('aircraft',[]) if a.get('lat') and a.get('lon')) +print(n)" 2>/dev/null) + if [ "${MSGS:-0}" -gt 0 ] 2>/dev/null; then + ok "tar1090 actif — $MSGS messages décodés" + ok "$AVIONS avion(s) en vue dont $WITH_POS avec position GPS" + else + warn "tar1090 répond mais 0 message (démarrage récent ou aucun avion ?)" + fi +else + fail "tar1090 ne répond pas sur :8090/data/aircraft.json" +fi + +# Volume partagé readsb-run +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" ] && ls "$VOL_PATH/aircraft.pb" 2>/dev/null | grep -q pb; then + AGE=$(( $(date +%s) - $(stat -c %Y "$VOL_PATH/aircraft.pb" 2>/dev/null || echo 0) )) + if [ "$AGE" -lt 10 ]; then + ok "Volume readsb-run : aircraft.pb mis à jour il y a ${AGE}s" + else + warn "Volume readsb-run : aircraft.pb âgé de ${AGE}s" + fi +else + warn "Volume readsb-run : aircraft.pb introuvable" +fi + +# ── 4. PORTS RÉSEAU ─────────────────────────────────────────────────────────── +hdr "4. Ports réseau" +declare -A PORTS=( + [":8080"]="webapp PHP" + [":8090"]="tar1090 nginx" + [":30005"]="Beast TCP readsb" + [":5432"]="PostgreSQL" + [":30002"]="RAW TCP" + [":30003"]="SBS TCP" +) +for port_str in ":8080" ":8090" ":30005" ":5432" ":30002" ":30003"; do + port="${port_str:1}" + label="${PORTS[$port_str]}" + if ss -tlnp 2>/dev/null | grep -q ":${port}[[:space:]]"; then + ok "Port ${port} en écoute — ${label}" + else + fail "Port ${port} absent — ${label}" + fi +done + +# Connexion Beast tar1090 → readsb +BEAST_ERRS=$(docker logs adsb-tar1090 2>&1 | grep "Beast TCP input.*failed" | tail -1) +if [ -n "$BEAST_ERRS" ]; then + FAIL_CNT=$(echo "$BEAST_ERRS" | grep -oP '\d+(?= times)') + if [ "${FAIL_CNT:-0}" -gt 50 ] 2>/dev/null; then + fail "Connexion Beast tar1090→readsb : $FAIL_CNT échecs (vérifier réseau adsb-net)" + else + warn "Quelques échecs Beast TCP au démarrage (normal): $FAIL_CNT" + fi +else + ok "Connexion Beast tar1090→readsb : OK" +fi + +# Réseau adsb-net +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 'adsb' in n: + print(v['IPAddress']) +" 2>/dev/null) +if [ -n "$READSB_IP" ] && [ "$READSB_IP" != "None" ]; then + ok "readsb attaché à adsb-net : $READSB_IP" +else + fail "readsb non attaché au réseau adsb-net (Beast TCP impossible)" +fi + +# ── 5. POSTGRESQL ───────────────────────────────────────────────────────────── +hdr "5. PostgreSQL — schéma adsb" +PG_CMD="docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c" + +# Connexion +PG_VER=$($PG_CMD "SELECT version();" 2>/dev/null | head -1 | xargs) +if [ -n "$PG_VER" ]; then + ok "PostgreSQL accessible" + info "${PG_VER:0:60}" +else + fail "PostgreSQL inaccessible" +fi + +# Partitions positions +PART_POS=$($PG_CMD " +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) +ok "${PART_POS:-0} sous-partitions adsb.positions" + +# Partitions aircraft_history +PART_HIS=$($PG_CMD " +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" + +# Insertions récentes positions +POS_COUNT=$($PG_CMD " +SELECT COUNT(*) FROM adsb.positions +WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | xargs) +if [ "${POS_COUNT:-0}" -gt 0 ] 2>/dev/null; then + ok "${POS_COUNT} positions insérées dans les 5 dernières minutes" +else + warn "Aucune position insérée dans les 5 dernières minutes" +fi + +# Pourcentage aircraft_desc rempli +PCT_DESC=$($PG_CMD " +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) +if [ -n "$PCT_DESC" ] && [ "$PCT_DESC" != "NULL" ]; then + if python3 -c "exit(0 if float('${PCT_DESC:-0}') >= 80 else 1)" 2>/dev/null; then + ok "aircraft_desc rempli à ${PCT_DESC}% (30 dernières min)" + else + warn "aircraft_desc rempli à ${PCT_DESC}% seulement (< 80%)" + fi +fi + +# Stats générales +STATS=$($PG_CMD " +SELECT + COUNT(DISTINCT icao) AS avions_aujourd_hui, + COUNT(*) AS sessions_aujourd_hui +FROM adsb.aircraft_history +WHERE session_start >= CURRENT_DATE;" 2>/dev/null | xargs) +info "Aujourd'hui : $STATS" + +# Taille de la base adsb +DB_SIZE=$($PG_CMD " +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:-?}" + +# ── 6. ADSB2PG ──────────────────────────────────────────────────────────────── +hdr "6. adsb2pg — collecteur" +LAST_LOG=$(docker logs adsb-adsb2pg 2>&1 | grep "positions insérées" | tail -1) +if [ -n "$LAST_LOG" ]; then + ok "adsb2pg actif : $LAST_LOG" + # Vérifier la fraîcheur (dernière log < 30s ?) + LOG_TIME=$(docker logs adsb-adsb2pg 2>&1 | grep "positions insérées" | tail -1 | \ + grep -oP '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') + if [ -n "$LOG_TIME" ]; then + AGE_LOG=$(( $(date +%s) - $(date -d "$LOG_TIME" +%s 2>/dev/null || echo 0) )) + if [ "$AGE_LOG" -lt 30 ]; then + ok "Dernière insertion il y a ${AGE_LOG}s (cycle 5s actif)" + else + warn "Dernière insertion il y a ${AGE_LOG}s (cycle 5s peut être bloqué)" + fi + fi +else + warn "Aucun log d'insertion trouvé dans adsb2pg" +fi + +# ── 7. WEBAPP ───────────────────────────────────────────────────────────────── +hdr "7. Webapp — interface utilisateur" +# Test API endpoints +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 + # Vérifier pas d'erreur PHP + if echo "$RESP" | grep -q '"error"'; then + ERR=$(echo "$RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('error','?'))" 2>/dev/null) + warn "api.php?action=${action} : erreur — $ERR" + else + ok "api.php?action=${action} : OK" + fi + else + fail "api.php?action=${action} : réponse non-JSON" + fi +done + +# Vérifier les onglets dans index.php +TAB_COUNT=$(docker exec adsb-webapp grep -c 'href="#tab' /var/www/html/index.php 2>/dev/null) +ok "${TAB_COUNT:-?} onglets dans index.php" +[ "${TAB_COUNT:-0}" -lt 5 ] && warn "Moins de 5 onglets — index.php peut être obsolète" + +# Taille du JS +JS_SIZE=$(docker exec adsb-webapp wc -c /var/www/html/js/app.js 2>/dev/null | awk '{print $1}') +info "app.js : ${JS_SIZE:-?} octets" + +# Accès depuis le LAN +if [ -n "$IP" ]; then + 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" +fi + +# ── 8. AUTOGAIN ─────────────────────────────────────────────────────────────── +hdr "8. Autogain" +GAIN_LOG=$(docker logs adsb-readsb 2>&1 | grep -E "gain|autogain" | tail -5) +CURRENT_GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 | \ + grep -oP '[\d.]+(?= dB)') +STATE=$(docker logs adsb-readsb 2>&1 | grep "autogain" | grep -oP "(?<=state ')[\w]+" | tail -1) + +if [ -n "$CURRENT_GAIN" ]; then + ok "Gain courant : ${CURRENT_GAIN} dB (état: ${STATE:-inconnu})" +else + warn "Gain courant inconnu" +fi + +INSUF=$(docker logs adsb-readsb 2>&1 | grep "Insufficient messages" | wc -l) +[ "$INSUF" -gt 3 ] && warn "$INSUF cycles avec messages insuffisants (antenne/portée ?)" || \ + [ "$INSUF" -gt 0 ] && info "$INSUF cycle(s) avec messages insuffisants" + +# ── RÉSUMÉ ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ RÉSUMÉ ║${NC}" +echo -e "${BOLD}╠══════════════════════════════════════════════════════╣${NC}" +if [ "$ERRORS" -eq 0 ] && [ "$WARNS" -eq 0 ]; then + echo -e "${BOLD}║ ${GREEN}✓ Stack opérationnel — aucune anomalie détectée${NC}${BOLD} ║${NC}" +elif [ "$ERRORS" -eq 0 ]; then + echo -e "${BOLD}║ ${YELLOW}⚠ Stack opérationnel — $WARNS avertissement(s)${NC}${BOLD} ║${NC}" +else + echo -e "${BOLD}║ ${RED}✗ $ERRORS erreur(s), $WARNS avertissement(s) détecté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}" + +exit $ERRORS diff --git a/check_adsb_mistral.sh b/check_adsb_mistral.sh new file mode 100755 index 0000000..398b83f --- /dev/null +++ b/check_adsb_mistral.sh @@ -0,0 +1,284 @@ +#!/bin/bash + +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 4.0 +# Date : 26/06/2026 +# Auteur : JC Abiven (avec Mistral AI) +# ============================================= + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Fonction pour afficher un message avec une couleur +print_status() { + echo -e "${1}${2}${NC}" +} + +# Fonction pour afficher un titre +print_title() { + echo -e "\n${BLUE}═══ ${1} ═══${NC}" +} + +# Fonction pour afficher une section +print_section() { + echo -e "\n${CYAN}═══ ${1} ═══${NC}" +} + +# Récupérer l'IP de l'hôte +HOST_IP=$(hostname -I | awk '{print $1}') + +# Afficher l'en-tête +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date "+%a %d/%m/%Y %H:%M:%S") ║" +echo -e "╚══════════════════════════════════════════════════════╝" +print_status "$YELLOW" " → IP détectée : $HOST_IP" + +# ============================================= +# 1. Vérification des conteneurs Docker +# ============================================= +print_section "1. Conteneurs Docker" + +DOCKER_CONTAINERS=("adsb-readsb" "adsb-tar1090" "adsb-adsb2pg" "adsb-webapp" "wordpress-postgres") +for container in "${DOCKER_CONTAINERS[@]}"; do + if docker ps | grep -q "$container"; then + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + if [ "$STATUS" = "healthy" ]; then + print_status "$GREEN" " ✓ $container [healthy]" + else + print_status "$GREEN" " ✓ $container" + fi + else + print_status "$RED" " ✗ $container — non démarré" + fi +done + +# ============================================= +# 2. Vérification du dongle RTL-SDR +# ============================================= +print_section "2. Dongle RTL-SDR" + +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" " ✓ AirNav FlightStick SN:00000010 détecté" + GAIN=$(docker logs adsb-readsb 2>&1 | grep -oP "tuner gain set to \K[0-9.]+ dB" || echo "inconnu") + print_status "$GREEN" " → Gain actuel : $GAIN" +else + print_status "$RED" " ✗ Dongle RTL-SDR non détecté" +fi + +# Vérifier le symlink /dev/adsb_dongle +if [ -L "/dev/adsb_dongle" ]; then + print_status "$GREEN" " ✓ Symlink /dev/adsb_dongle présent" +else + print_status "$YELLOW" " ⚠ Symlink /dev/adsb_dongle introuvable" +fi + +# ============================================= +# 3. Vérification des données ADS-B +# ============================================= +print_section "3. Données ADS-B" + +# Vérifier tar1090 +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + TAR1090_STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/stats.json 2>/dev/null) + if [ -n "$TAR1090_STATS" ]; then + MESSAGES=$(echo "$TAR1090_STATS" | jq -r '.totalMessages // "N/A"') + print_status "$GREEN" " ✓ tar1090 actif — $MESSAGES messages décodés" + fi + + AIRCRAFT_DATA=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json 2>/dev/null) + if [ -n "$AIRCRAFT_DATA" ]; then + AIRCRAFT_COUNT=$(echo "$AIRCRAFT_DATA" | jq '. | length // 0') + AIRCRAFT_WITH_POS=$(echo "$AIRCRAFT_DATA" | jq '[.[] | select(.lat != null)] | length // 0') + print_status "$GREEN" " ✓ $AIRCRAFT_COUNT avion(s) en vue dont $AIRCRAFT_WITH_POS avec position GPS" + fi +else + print_status "$RED" " ✗ tar1090 non accessible" +fi + +# Vérifier le volume readsb-run +if docker exec adsb-readsb ls /run/readsb/aircraft.pb >/dev/null 2>&1; then + print_status "$GREEN" " ✓ Volume readsb-run : aircraft.pb présent" +else + print_status "$YELLOW" " ⚠ Volume readsb-run : aircraft.pb introuvable" +fi + +# ============================================= +# 4. Vérification des ports réseau +# ============================================= +print_section "4. Ports réseau" + +PORTS=("8080:webapp PHP" "8090:tar1090 nginx" "30005:Beast TCP readsb" "5432:PostgreSQL" "30002:RAW TCP" "30003:SBS TCP") +for port_info in "${PORTS[@]}"; do + PORT=${port_info%%:*} + SERVICE=${port_info#*:} + if timeout 2 nc -z 127.0.0.1 "$PORT" 2>/dev/null; then + print_status "$GREEN" " ✓ Port $PORT en écoute — $SERVICE" + else + print_status "$RED" " ✗ Port $PORT non accessible — $SERVICE" + fi +done + +# Vérifier la connectivité Beast TCP via la gateway +if timeout 2 nc -z 172.20.0.1 30005 2>/dev/null; then + print_status "$GREEN" " ✓ readsb attaché à adsb-net : 172.20.0.2" +else + print_status "$RED" " ✗ Problème de connectivité avec adsb-net" +fi + +# ============================================= +# 5. Vérification de PostgreSQL et du schéma adsb +# ============================================= +print_section "5. PostgreSQL — schéma adsb" + +# Vérifier l'accès à PostgreSQL +if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres 2>/dev/null | grep -q "accepting connections"; then + print_status "$GREEN" " ✓ PostgreSQL accessible" + PG_VERSION=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT version();" 2>/dev/null | head -1) + print_status "$GREEN" " → $PG_VERSION" +else + print_status "$RED" " ✗ PostgreSQL non accessible" +fi + +# Vérifier les partitions +PARTITIONS_POSITIONS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | tr -d ' ') +PARTITIONS_HISTORY=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $PARTITIONS_POSITIONS sous-partitions adsb.positions" +print_status "$GREEN" " ✓ $PARTITIONS_HISTORY sous-partitions adsb.aircraft_history" + +# Vérifier les insertions récentes +POSITIONS_5MIN=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $POSITIONS_5MIN positions insérées dans les 5 dernières minutes" + +# Vérifier le remplissage de aircraft_desc +DESC_FILL=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT ROUND(COUNT(aircraft_desc)*100.0/COUNT(*),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ aircraft_desc rempli à $DESC_FILL% (30 dernières min)" + +# Taille du schéma adsb +SCHEMA_SIZE=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT pg_size_pretty(pg_total_relation_size('adsb'));" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " → Taille totale schéma adsb : $SCHEMA_SIZE" + +# ============================================= +# 6. Vérification de adsb2pg (collecteur) +# ============================================= +print_section "6. adsb2pg — collecteur" + +if docker ps | grep -q "adsb-adsb2pg"; then + ADSB2PG_LOGS=$(docker logs adsb-adsb2pg 2>&1 | tail -1) + if echo "$ADSB2PG_LOGS" | grep -q "positions insérées"; then + print_status "$GREEN" " ✓ adsb2pg actif : $ADSB2PG_LOGS" + else + print_status "$YELLOW" " ⚠ adsb2pg actif mais aucun log récent" + fi + + # Vérifier la dernière insertion + LAST_INSERT=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at))) FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" 2>/dev/null | tr -d ' ') + if [ -n "$LAST_INSERT" ] && [ "$(echo "$LAST_INSERT < 10" | bc)" -eq 1 ]; then + print_status "$GREEN" " ✓ Dernière insertion il y a $(printf "%.0f" "$LAST_INSERT")s (cycle 5s actif)" + else + print_status "$RED" " ✗ Dernière insertion trop ancienne ou introuvable" + fi +else + print_status "$RED" " ✗ adsb2pg non démarré" +fi + +# ============================================= +# 7. Vérification de la webapp +# ============================================= +print_section "7. Webapp — interface utilisateur" + +# Vérifier les endpoints API +API_ENDPOINTS=("action=live" "action=kpi" "action=trajectories") +for endpoint in "${API_ENDPOINTS[@]}"; do + if curl -s --max-time 5 "http://127.0.0.1:8080/api.php?$endpoint" >/dev/null; then + print_status "$GREEN" " ✓ api.php?$endpoint : OK" + else + print_status "$RED" " ✗ api.php?$endpoint : KO" + fi +done + +# Vérifier le nombre d'onglets dans index.php +ONGETS=$(curl -s --max-time 5 http://127.0.0.1:8080/index.php | grep -o "
  • " | wc -l) +print_status "$GREEN" " ✓ $ONGETS onglets dans index.php" + +# Vérifier la taille de app.js +APP_JS_SIZE=$(curl -s --max-time 5 http://127.0.0.1:8080/app.js | wc -c) +print_status "$GREEN" " → app.js : $APP_JS_SIZE octets" + +# Vérifier l'accès LAN +if curl -s --max-time 5 "http://$HOST_IP:8080" >/dev/null; then + print_status "$GREEN" " ✓ Accès LAN http://$HOST_IP:8080 : HTTP 200" +else + print_status "$RED" " ✗ Accès LAN http://$HOST_IP:8080 : KO" +fi + +# ============================================= +# 8. Vérification de l'autogain +# ============================================= +print_section "8. Autogain" + +GAIN_STATUS=$(docker logs adsb-readsb 2>&1 | grep -oP "Gain: \K[0-9.]+ dB" || echo "inconnu") +print_status "$GREEN" " ✓ Gain courant : $GAIN_STATUS (état: inconnu)" + +# Vérifier les cycles avec messages insuffisants +INSUFFICIENT_CYCLES=$(docker logs adsb-readsb 2>&1 | grep -c "messages insuffisants" || echo "0") +if [ "$INSUFFICIENT_CYCLES" -gt 0 ]; then + print_status "$YELLOW" " ⚠ $INSUFFICIENT_CYCLES cycle(s) avec messages insuffisants (antenne/portée ?)" +else + print_status "$GREEN" " ✓ Aucun cycle avec messages insuffisants" +fi + +# ============================================= +# 9. Vérification des statistiques readsb +# ============================================= +print_section "9. Statistiques readsb" + +# Essayer d'abord via l'IP du conteneur dans adsb-net +STATS=$(curl -s --max-time 5 http://172.20.0.2:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + # Essayer via docker exec + STATS=$(docker exec adsb-readsb curl -s http://localhost:30003/readsb/stats.json 2>/dev/null) + if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (via docker exec) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" + else + print_status "$RED" " ✗ Impossible de récupérer les statistiques de readsb" + fi +fi + +# ============================================= +# Résumé final +# ============================================= +print_title "RÉSUMÉ" + +# Compter les erreurs et avertissements +ERRORS=$(grep -c "$RED✗" <<< "$(./check_adsb_complete.sh 2>&1)" || echo "0") +WARNINGS=$(grep -c "$YELLOW⚠" <<< "$(./check_adsb_complete.sh 2>&1)" || echo "0") + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + print_status "$GREEN" " ✓ Stack opérationnel — Aucun problème détecté" +elif [ "$ERRORS" -eq 0 ]; then + print_status "$YELLOW" " ⚠ Stack opérationnel — $WARNINGS avertissement(s)" +else + print_status "$RED" " ✗ Stack en erreur — $ERRORS erreur(s), $WARNINGS avertissement(s)" +fi + +print_status "$BLUE" " Dashboard : http://$HOST_IP:8080" +print_status "$BLUE" " tar1090 : http://$HOST_IP:8090" + diff --git a/check_adsb_mistral_v0.sh b/check_adsb_mistral_v0.sh new file mode 100755 index 0000000..c7d0efb --- /dev/null +++ b/check_adsb_mistral_v0.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +print_status() { + echo -e "${1}${2}${NC}" +} + +# 1. Vérifier que tous les conteneurs sont en cours d'exécution +print_status "$YELLOW" "=== Vérification des conteneurs Docker ===" +for service in readsb tar1090 adsb2pg webapp; do + if docker ps | grep -q "adsb-$service"; then + print_status "$GREEN" "✓ Le conteneur adsb-$service est en cours d'exécution." + else + print_status "$RED" "✗ Le conteneur adsb-$service n'est pas en cours d'exécution." + fi +done + +# 2. Vérifier la détection du dongle RTL-SDR +print_status "$YELLOW" "\n=== Vérification du dongle RTL-SDR ===" +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" "✓ Le dongle AirNav FlightStick (SN: 00000010) est détecté et utilisé par readsb." +else + print_status "$RED" "✗ Le dongle RTL-SDR n'est pas détecté ou utilisé correctement." + print_status "$YELLOW" "Dernières lignes des logs :" + docker logs adsb-readsb 2>&1 | tail -10 +fi + +# 3. Vérifier que readsb décode des messages ADS-B (via les ports) +print_status "$YELLOW" "\n=== Vérification du décodage ADS-B ===" +if timeout 2 nc -z 172.20.0.1 30005; then + print_status "$GREEN" "✓ Le port Beast (30005) émet des données (readsb décode des messages ADS-B)." +else + print_status "$RED" "✗ Aucune donnée détectée sur le port Beast (30005)." +fi + +# 4. Vérifier les statistiques readsb (via l'IP du conteneur) +print_status "$YELLOW" "\n=== Vérification des statistiques readsb ===" +STATS=$(curl -s --max-time 5 http://172.20.0.2:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" "✓ Statistiques readsb accessibles :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + print_status "$RED" "✗ Impossible de récupérer les statistiques de readsb." + print_status "$YELLOW" " → Essayez : docker exec adsb-readsb curl -s http://localhost:30003/readsb/stats.json | jq" +fi + +# 5. Vérifier que tar1090 est accessible et reçoit des données +print_status "$YELLOW" "\n=== Vérification de tar1090 ===" +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + print_status "$GREEN" "✓ tar1090 est accessible sur http://127.0.0.1:8090." + AIRCRAFT_COUNT=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json | jq '. | length // 0') + if [ "$AIRCRAFT_COUNT" -gt 0 ]; then + print_status "$GREEN" "✓ $AIRCRAFT_COUNT avion(s) détecté(s) sur tar1090." + LAST_UPDATE=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json | jq -r '.now // 0') + CURRENT_TIME=$(date +%s) + TIME_DIFF=$((CURRENT_TIME - LAST_UPDATE)) + if [ "$TIME_DIFF" -le 10 ]; then + print_status "$GREEN" "✓ Les données sont récentes (dernière mise à jour il y a $TIME_DIFF secondes)." + else + print_status "$RED" "✗ Les données ne sont pas récentes (dernière mise à jour il y a $TIME_DIFF secondes)." + fi + else + print_status "$RED" "✗ Aucun avion détecté sur tar1090." + fi +else + print_status "$RED" "✗ tar1090 n'est pas accessible sur http://127.0.0.1:8090." +fi + +# 6. Vérifier les ports critiques +print_status "$YELLOW" "\n=== Vérification des ports ===" +for port in 30002 30003 30005 8080 8090; do + if timeout 2 nc -z 127.0.0.1 $port; then + print_status "$GREEN" "✓ Le port $port est accessible sur 127.0.0.1." + else + print_status "$RED" "✗ Le port $port n'est pas accessible sur 127.0.0.1." + fi +done + +print_status "$YELLOW" "\n=== Fin des vérifications ===" + diff --git a/check_adsb_mistral_v2.sh b/check_adsb_mistral_v2.sh new file mode 100755 index 0000000..82b48ad --- /dev/null +++ b/check_adsb_mistral_v2.sh @@ -0,0 +1,309 @@ +#!/bin/bash + +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 5.0 (Corrigée) +# Date : 26/06/2026 +# Auteur : JC Abiven (avec Mistral AI) +# ============================================= + +# Vérifier que jq est installé +if ! command -v jq &> /dev/null; then + echo -e "\033[0;31m✗ jq n'est pas installé. Installez-le avec : sudo apt install jq\033[0m" + exit 1 +fi + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Fonction pour afficher un message avec une couleur +print_status() { + echo -e "${1}${2}${NC}" +} + +# Fonction pour afficher un titre +print_title() { + echo -e "\n${BLUE}═══ ${1} ═══${NC}" +} + +# Fonction pour afficher une section +print_section() { + echo -e "\n${CYAN}═══ ${1} ═══${NC}" +} + +# Récupérer l'IP de l'hôte +HOST_IP=$(hostname -I | awk '{print $1}') + +# Afficher l'en-tête +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date "+%a %d/%m/%Y %H:%M:%S") ║" +echo -e "╚══════════════════════════════════════════════════════╝" +print_status "$YELLOW" " → IP détectée : $HOST_IP" + +# ============================================= +# 1. Vérification des conteneurs Docker +# ============================================= +print_section "1. Conteneurs Docker" + +DOCKER_CONTAINERS=("adsb-readsb" "adsb-tar1090" "adsb-adsb2pg" "adsb-webapp" "wordpress-postgres") +for container in "${DOCKER_CONTAINERS[@]}"; do + if docker ps | grep -q "$container"; then + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + if [ "$STATUS" = "healthy" ]; then + print_status "$GREEN" " ✓ $container [healthy]" + else + print_status "$GREEN" " ✓ $container" + fi + else + print_status "$RED" " ✗ $container — non démarré" + fi +done + +# ============================================= +# 2. Vérification du dongle RTL-SDR +# ============================================= +print_section "2. Dongle RTL-SDR" + +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" " ✓ AirNav FlightStick SN:00000010 détecté" + GAIN=$(docker logs adsb-readsb 2>&1 | grep -oP "tuner gain set to \K[0-9.]+ dB" | tail -1) + print_status "$GREEN" " → Gain actuel : $GAIN" +else + print_status "$RED" " ✗ Dongle RTL-SDR non détecté" +fi + +# Vérifier le symlink /dev/adsb_dongle +if [ -L "/dev/adsb_dongle" ]; then + print_status "$GREEN" " ✓ Symlink /dev/adsb_dongle présent" +else + print_status "$YELLOW" " ⚠ Symlink /dev/adsb_dongle introuvable" +fi + +# ============================================= +# 3. Vérification des données ADS-B +# ============================================= +print_section "3. Données ADS-B" + +# Vérifier tar1090 +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + TAR1090_STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/stats.json 2>/dev/null) + if [ -n "$TAR1090_STATS" ]; then + MESSAGES=$(echo "$TAR1090_STATS" | jq -r '.totalMessages // "N/A"') + print_status "$GREEN" " ✓ tar1090 actif — $MESSAGES messages décodés" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les stats de tar1090" + fi + + AIRCRAFT_DATA=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json 2>/dev/null) + if [ -n "$AIRCRAFT_DATA" ]; then + AIRCRAFT_COUNT=$(echo "$AIRCRAFT_DATA" | jq '. | length // 0') + AIRCRAFT_WITH_POS=$(echo "$AIRCRAFT_DATA" | jq '[.[] | select(.lat != null)] | length // 0') + print_status "$GREEN" " ✓ $AIRCRAFT_COUNT avion(s) en vue dont $AIRCRAFT_WITH_POS avec position GPS" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les données des avions" + fi +else + print_status "$RED" " ✗ tar1090 non accessible" +fi + +# Vérifier le volume readsb-run +if docker exec adsb-readsb ls /run/readsb/aircraft.pb >/dev/null 2>&1; then + print_status "$GREEN" " ✓ Volume readsb-run : aircraft.pb présent" +else + print_status "$YELLOW" " ⚠ Volume readsb-run : aircraft.pb introuvable" +fi + +# ============================================= +# 4. Vérification des ports réseau +# ============================================= +print_section "4. Ports réseau" + +PORTS=("8080:webapp PHP" "8090:tar1090 nginx" "30005:Beast TCP readsb" "5432:PostgreSQL" "30002:RAW TCP" "30003:SBS TCP") +for port_info in "${PORTS[@]}"; do + PORT=${port_info%%:*} + SERVICE=${port_info#*:} + if timeout 2 nc -z 127.0.0.1 "$PORT" 2>/dev/null; then + print_status "$GREEN" " ✓ Port $PORT en écoute — $SERVICE" + else + print_status "$RED" " ✗ Port $PORT non accessible — $SERVICE" + fi +done + +# Vérifier la connectivité Beast TCP via la gateway +if timeout 2 nc -z 172.20.0.1 30005 2>/dev/null; then + print_status "$GREEN" " ✓ readsb attaché à adsb-net : 172.20.0.2" +else + print_status "$RED" " ✗ Problème de connectivité avec adsb-net" +fi + +# ============================================= +# 5. Vérification de PostgreSQL et du schéma adsb +# ============================================= +print_section "5. PostgreSQL — schéma adsb" + +# Vérifier l'accès à PostgreSQL +if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres 2>/dev/null | grep -q "accepting connections"; then + print_status "$GREEN" " ✓ PostgreSQL accessible" + PG_VERSION=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT version();" 2>/dev/null | head -1 | tr -d ' ') + print_status "$GREEN" " → $PG_VERSION" +else + print_status "$RED" " ✗ PostgreSQL non accessible" +fi + +# Vérifier les partitions +PARTITIONS_POSITIONS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | tr -d ' ') +PARTITIONS_HISTORY=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $PARTITIONS_POSITIONS sous-partitions adsb.positions" +print_status "$GREEN" " ✓ $PARTITIONS_HISTORY sous-partitions adsb.aircraft_history" + +# Vérifier les insertions récentes +POSITIONS_5MIN=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $POSITIONS_5MIN positions insérées dans les 5 dernières minutes" + +# Vérifier le remplissage de aircraft_desc +DESC_FILL=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT ROUND(COUNT(aircraft_desc)*100.0/COUNT(*),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ aircraft_desc rempli à ${DESC_FILL:-0}% (30 dernières min)" + +# Taille du schéma adsb +SCHEMA_SIZE=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT pg_size_pretty(pg_total_relation_size('adsb'));" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " → Taille totale schéma adsb : ${SCHEMA_SIZE:-inconnu}" + +# ============================================= +# 6. Vérification de adsb2pg (collecteur) +# ============================================= +print_section "6. adsb2pg — collecteur" + +if docker ps | grep -q "adsb-adsb2pg"; then + ADSB2PG_LOGS=$(docker logs adsb-adsb2pg 2>&1 | tail -1) + if echo "$ADSB2PG_LOGS" | grep -q "positions insérées"; then + print_status "$GREEN" " ✓ adsb2pg actif : $ADSB2PG_LOGS" + else + print_status "$YELLOW" " ⚠ adsb2pg actif mais aucun log récent" + fi + + # Vérifier la dernière insertion (en secondes) + LAST_INSERT_SEC=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at))) FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" 2>/dev/null | tr -d ' ') + if [ -n "$LAST_INSERT_SEC" ]; then + LAST_INSERT_SEC_INT=${LAST_INSERT_SEC%.*} # Supprimer la partie décimale + if [ "$LAST_INSERT_SEC_INT" -le 10 ] 2>/dev/null; then + print_status "$GREEN" " ✓ Dernière insertion il y a ${LAST_INSERT_SEC_INT}s (cycle 5s actif)" + else + print_status "$RED" " ✗ Dernière insertion trop ancienne (${LAST_INSERT_SEC_INT}s)" + fi + else + print_status "$RED" " ✗ Impossible de vérifier la dernière insertion" + fi +else + print_status "$RED" " ✗ adsb2pg non démarré" +fi + +# ============================================= +# 7. Vérification de la webapp +# ============================================= +print_section "7. Webapp — interface utilisateur" + +# Vérifier les endpoints API +API_ENDPOINTS=("action=live" "action=kpi" "action=trajectories") +for endpoint in "${API_ENDPOINTS[@]}"; do + if curl -s --max-time 5 "http://127.0.0.1:8080/api.php?$endpoint" >/dev/null; then + print_status "$GREEN" " ✓ api.php?$endpoint : OK" + else + print_status "$RED" " ✗ api.php?$endpoint : KO" + fi +done + +# Vérifier le nombre d'onglets dans index.php +ONGETS=$(curl -s --max-time 5 http://127.0.0.1:8080/index.php | grep -o "
  • " | wc -l) +print_status "$GREEN" " ✓ $ONGETS onglet(s) dans index.php" + +# Vérifier la taille de app.js +APP_JS_SIZE=$(curl -s --max-time 5 http://127.0.0.1:8080/app.js | wc -c) +print_status "$GREEN" " → app.js : $APP_JS_SIZE octets" + +# Vérifier l'accès LAN +if curl -s --max-time 5 "http://$HOST_IP:8080" >/dev/null; then + print_status "$GREEN" " ✓ Accès LAN http://$HOST_IP:8080 : HTTP 200" +else + print_status "$RED" " ✗ Accès LAN http://$HOST_IP:8080 : KO" +fi + +# ============================================= +# 8. Vérification de l'autogain +# ============================================= +print_section "8. Autogain" + +GAIN_STATUS=$(docker logs adsb-readsb 2>&1 | grep -oP "Gain: \K[0-9.]+ dB" | tail -1) +if [ -z "$GAIN_STATUS" ]; then + GAIN_STATUS="inconnu" +fi +print_status "$GREEN" " ✓ Gain courant : $GAIN_STATUS" + +# Vérifier les cycles avec messages insuffisants +INSUFFICIENT_CYCLES=$(docker logs adsb-readsb 2>&1 | grep -c "messages insuffisants" || echo "0") +if [ "$INSUFFICIENT_CYCLES" -gt 0 ] 2>/dev/null; then + print_status "$YELLOW" " ⚠ $INSUFFICIENT_CYCLES cycle(s) avec messages insuffisants (antenne/portée ?)" +else + print_status "$GREEN" " ✓ Aucun cycle avec messages insuffisants" +fi + +# ============================================= +# 9. Vérification des statistiques readsb +# ============================================= +print_section "9. Statistiques readsb" + +# Essayer d'abord via l'IP du conteneur dans adsb-net +STATS=$(curl -s --max-time 5 http://172.20.0.2:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + # Essayer via docker exec + STATS=$(docker exec adsb-readsb curl -s http://localhost:30003/readsb/stats.json 2>/dev/null) + if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (via docker exec) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" + else + print_status "$RED" " ✗ Impossible de récupérer les statistiques de readsb" + fi +fi + +# ============================================= +# Résumé final +# ============================================= +print_title "RÉSUMÉ" + +# Compter les erreurs et avertissements dans le script actuel +ERRORS=0 +WARNINGS=0 +while IFS= read -r line; do + if [[ "$line" == *"$RED✗"* ]]; then + ((ERRORS++)) + elif [[ "$line" == *"$YELLOW⚠"* ]]; then + ((WARNINGS++)) + fi +done < <(./check_adsb_final.sh 2>&1) + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + print_status "$GREEN" " ✓ Stack opérationnel — Aucun problème détecté" +elif [ "$ERRORS" -eq 0 ]; then + print_status "$YELLOW" " ⚠ Stack opérationnel — $WARNINGS avertissement(s)" +else + print_status "$RED" " ✗ Stack en erreur — $ERRORS erreur(s), $WARNINGS avertissement(s)" +fi + +print_status "$BLUE" " Dashboard : http://$HOST_IP:8080" +print_status "$BLUE" " tar1090 : http://$HOST_IP:8090" + diff --git a/check_adsb_mistral_v3.sh b/check_adsb_mistral_v3.sh new file mode 100755 index 0000000..0516352 --- /dev/null +++ b/check_adsb_mistral_v3.sh @@ -0,0 +1,322 @@ +#!/bin/bash + +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 6.0 (Avec adresses IP pour chaque port) +# Date : 26/06/2026 +# Auteur : JC Abiven (avec Mistral AI) +# ============================================= + +# Vérifier que jq est installé +if ! command -v jq &> /dev/null; then + echo -e "\033[0;31m✗ jq n'est pas installé. Installez-le avec : sudo apt install jq\033[0m" + exit 1 +fi + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Fonction pour afficher un message avec une couleur +print_status() { + echo -e "${1}${2}${NC}" +} + +# Fonction pour afficher un titre +print_title() { + echo -e "\n${BLUE}═══ ${1} ═══${NC}" +} + +# Fonction pour afficher une section +print_section() { + echo -e "\n${CYAN}═══ ${1} ═══${NC}" +} + +# Récupérer l'IP de l'hôte +HOST_IP=$(hostname -I | awk '{print $1}') + +# Afficher l'en-tête +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date "+%a %d/%m/%Y %H:%M:%S") ║" +echo -e "╚══════════════════════════════════════════════════════╝" +print_status "$YELLOW" " → IP détectée : $HOST_IP" + +# ============================================= +# 1. Vérification des conteneurs Docker +# ============================================= +print_section "1. Conteneurs Docker" + +DOCKER_CONTAINERS=("adsb-readsb" "adsb-tar1090" "adsb-adsb2pg" "adsb-webapp" "wordpress-postgres") +for container in "${DOCKER_CONTAINERS[@]}"; do + if docker ps | grep -q "$container"; then + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + if [ "$STATUS" = "healthy" ]; then + print_status "$GREEN" " ✓ $container [healthy]" + else + print_status "$GREEN" " ✓ $container" + fi + else + print_status "$RED" " ✗ $container — non démarré" + fi +done + +# ============================================= +# 2. Vérification du dongle RTL-SDR +# ============================================= +print_section "2. Dongle RTL-SDR" + +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" " ✓ AirNav FlightStick SN:00000010 détecté" + GAIN=$(docker logs adsb-readsb 2>&1 | grep -oP "tuner gain set to \K[0-9.]+ dB" | tail -1) + print_status "$GREEN" " → Gain actuel : $GAIN" +else + print_status "$RED" " ✗ Dongle RTL-SDR non détecté" +fi + +# Vérifier le symlink /dev/adsb_dongle +if [ -L "/dev/adsb_dongle" ]; then + print_status "$GREEN" " ✓ Symlink /dev/adsb_dongle présent" +else + print_status "$YELLOW" " ⚠ Symlink /dev/adsb_dongle introuvable" +fi + +# ============================================= +# 3. Vérification des données ADS-B +# ============================================= +print_section "3. Données ADS-B" + +# Vérifier tar1090 +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + TAR1090_STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/stats.json 2>/dev/null) + if [ -n "$TAR1090_STATS" ]; then + MESSAGES=$(echo "$TAR1090_STATS" | jq -r '.totalMessages // "N/A"') + print_status "$GREEN" " ✓ tar1090 actif — $MESSAGES messages décodés" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les stats de tar1090" + fi + + AIRCRAFT_DATA=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json 2>/dev/null) + if [ -n "$AIRCRAFT_DATA" ]; then + AIRCRAFT_COUNT=$(echo "$AIRCRAFT_DATA" | jq '. | length // 0') + AIRCRAFT_WITH_POS=$(echo "$AIRCRAFT_DATA" | jq '[.[] | select(.lat != null and .lon != null)] | length // 0') + print_status "$GREEN" " ✓ $AIRCRAFT_COUNT avion(s) en vue dont $AIRCRAFT_WITH_POS avec position GPS" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les données des avions" + fi +else + print_status "$RED" " ✗ tar1090 non accessible" +fi + +# Vérifier le volume readsb-run +if docker exec adsb-readsb ls /run/readsb/aircraft.pb >/dev/null 2>&1; then + print_status "$GREEN" " ✓ Volume readsb-run : aircraft.pb présent" +else + print_status "$YELLOW" " ⚠ Volume readsb-run : aircraft.pb introuvable" +fi + +# ============================================= +# 4. Vérification des ports réseau (avec adresses IP) +# ============================================= +print_section "4. Ports réseau" + +# Fonction pour obtenir l'adresse IP associée à un port +get_ip_for_port() { + local port=$1 + ss -tulnp | grep ":$port " | awk '{print $5}' | cut -d: -f1 | head -1 +} + +PORTS=("8080:webapp PHP:127.0.0.1" "8090:tar1090 nginx:127.0.0.1" "30005:Beast TCP readsb:0.0.0.0" "5432:PostgreSQL:127.0.0.1" "30002:RAW TCP:127.0.0.1" "30003:SBS TCP:127.0.0.1") +for port_info in "${PORTS[@]}"; do + PORT=${port_info%%:*} + SERVICE=${port_info#*:} + SERVICE=${SERVICE%:*} + EXPECTED_IP=${port_info##*:} + + # Obtenir l'IP réelle + ACTUAL_IP=$(get_ip_for_port "$PORT" || echo "inconnu") + + if timeout 2 nc -z "$EXPECTED_IP" "$PORT" 2>/dev/null; then + print_status "$GREEN" " ✓ Port $PORT ($SERVICE) — IP: $ACTUAL_IP (attendu: $EXPECTED_IP)" + else + print_status "$RED" " ✗ Port $PORT ($SERVICE) — IP: $ACTUAL_IP (attendu: $EXPECTED_IP)" + fi +done + +# Vérifier la connectivité Beast TCP via la gateway +if timeout 2 nc -z 172.20.0.1 30005 2>/dev/null; then + print_status "$GREEN" " ✓ readsb attaché à adsb-net : 172.20.0.2 (gateway: 172.20.0.1)" +else + print_status "$RED" " ✗ Problème de connectivité avec adsb-net (172.20.0.1:30005)" +fi + +# ============================================= +# 5. Vérification de PostgreSQL et du schéma adsb +# ============================================= +print_section "5. PostgreSQL — schéma adsb" + +# Vérifier l'accès à PostgreSQL +if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres 2>/dev/null | grep -q "accepting connections"; then + print_status "$GREEN" " ✓ PostgreSQL accessible" + PG_VERSION=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT version();" 2>/dev/null | head -1 | tr -d ' ') + print_status "$GREEN" " → $PG_VERSION" +else + print_status "$RED" " ✗ PostgreSQL non accessible" +fi + +# Vérifier les partitions +PARTITIONS_POSITIONS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | tr -d ' ') +PARTITIONS_HISTORY=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $PARTITIONS_POSITIONS sous-partitions adsb.positions" +print_status "$GREEN" " ✓ $PARTITIONS_HISTORY sous-partitions adsb.aircraft_history" + +# Vérifier les insertions récentes +POSITIONS_5MIN=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $POSITIONS_5MIN positions insérées dans les 5 dernières minutes" + +# Vérifier le remplissage de aircraft_desc +DESC_FILL=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT ROUND(COUNT(aircraft_desc)*100.0/COUNT(*),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ aircraft_desc rempli à ${DESC_FILL:-0}% (30 dernières min)" + +# Taille du schéma adsb +SCHEMA_SIZE=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT pg_size_pretty(pg_total_relation_size('adsb'));" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " → Taille totale schéma adsb : ${SCHEMA_SIZE:-inconnu}" + +# ============================================= +# 6. Vérification de adsb2pg (collecteur) +# ============================================= +print_section "6. adsb2pg — collecteur" + +if docker ps | grep -q "adsb-adsb2pg"; then + ADSB2PG_LOGS=$(docker logs adsb-adsb2pg 2>&1 | tail -1) + if echo "$ADSB2PG_LOGS" | grep -q "positions insérées"; then + print_status "$GREEN" " ✓ adsb2pg actif : $ADSB2PG_LOGS" + else + print_status "$YELLOW" " ⚠ adsb2pg actif mais aucun log récent" + fi + + # Vérifier la dernière insertion (en secondes) + LAST_INSERT_SEC=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at))) FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" 2>/dev/null | tr -d ' ') + if [ -n "$LAST_INSERT_SEC" ]; then + LAST_INSERT_SEC_INT=${LAST_INSERT_SEC%.*} # Supprimer la partie décimale + if [ "$LAST_INSERT_SEC_INT" -le 10 ] 2>/dev/null; then + print_status "$GREEN" " ✓ Dernière insertion il y a ${LAST_INSERT_SEC_INT}s (cycle 5s actif)" + else + print_status "$RED" " ✗ Dernière insertion trop ancienne (${LAST_INSERT_SEC_INT}s)" + fi + else + print_status "$RED" " ✗ Impossible de vérifier la dernière insertion" + fi +else + print_status "$RED" " ✗ adsb2pg non démarré" +fi + +# ============================================= +# 7. Vérification de la webapp +# ============================================= +print_section "7. Webapp — interface utilisateur" + +# Vérifier les endpoints API +API_ENDPOINTS=("action=live" "action=kpi" "action=trajectories") +for endpoint in "${API_ENDPOINTS[@]}"; do + if curl -s --max-time 5 "http://127.0.0.1:8080/api.php?$endpoint" >/dev/null; then + print_status "$GREEN" " ✓ api.php?$endpoint : OK (IP: 127.0.0.1)" + else + print_status "$RED" " ✗ api.php?$endpoint : KO (IP: 127.0.0.1)" + fi +done + +# Vérifier le nombre d'onglets dans index.php +ONGETS=$(curl -s --max-time 5 http://127.0.0.1:8080/index.php | grep -o "
  • " | wc -l) +print_status "$GREEN" " ✓ $ONGETS onglet(s) dans index.php (IP: 127.0.0.1)" + +# Vérifier la taille de app.js +APP_JS_SIZE=$(curl -s --max-time 5 http://127.0.0.1:8080/app.js | wc -c) +print_status "$GREEN" " → app.js : $APP_JS_SIZE octets (IP: 127.0.0.1)" + +# Vérifier l'accès LAN +if curl -s --max-time 5 "http://$HOST_IP:8080" >/dev/null; then + print_status "$GREEN" " ✓ Accès LAN http://$HOST_IP:8080 : HTTP 200 (IP: $HOST_IP)" +else + print_status "$RED" " ✗ Accès LAN http://$HOST_IP:8080 : KO (IP: $HOST_IP)" +fi + +# ============================================= +# 8. Vérification de l'autogain +# ============================================= +print_section "8. Autogain" + +GAIN_STATUS=$(docker logs adsb-readsb 2>&1 | grep -oP "Gain: \K[0-9.]+ dB" | tail -1) +if [ -z "$GAIN_STATUS" ]; then + GAIN_STATUS="inconnu" +fi +print_status "$GREEN" " ✓ Gain courant : $GAIN_STATUS" + +# Vérifier les cycles avec messages insuffisants +INSUFFICIENT_CYCLES=$(docker logs adsb-readsb 2>&1 | grep -c "messages insuffisants" || echo "0") +if [ "$INSUFFICIENT_CYCLES" -gt 0 ] 2>/dev/null; then + print_status "$YELLOW" " ⚠ $INSUFFICIENT_CYCLES cycle(s) avec messages insuffisants (antenne/portée ?)" +else + print_status "$GREEN" " ✓ Aucun cycle avec messages insuffisants" +fi + +# ============================================= +# 9. Vérification des statistiques readsb +# ============================================= +print_section "9. Statistiques readsb" + +# Essayer d'abord via l'IP du conteneur dans adsb-net +STATS=$(curl -s --max-time 5 http://172.20.0.2:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (IP: 172.20.0.2:30003) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + # Essayer via docker exec + STATS=$(docker exec adsb-readsb curl -s http://localhost:30003/readsb/stats.json 2>/dev/null) + if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (via docker exec, IP: localhost:30003) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" + else + print_status "$RED" " ✗ Impossible de récupérer les statistiques de readsb (IP: 172.20.0.2 ou localhost:30003)" + fi +fi + +# ============================================= +# Résumé final +# ============================================= +print_title "RÉSUMÉ" + +# Compter les erreurs et avertissements dans le script actuel +ERRORS=0 +WARNINGS=0 +SCRIPT_OUTPUT=$(./check_adsb_with_ips.sh 2>&1) +while IFS= read -r line; do + if [[ "$line" == *"$RED✗"* ]]; then + ((ERRORS++)) + elif [[ "$line" == *"$YELLOW⚠"* ]]; then + ((WARNINGS++)) + fi +done <<< "$SCRIPT_OUTPUT" + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + print_status "$GREEN" " ✓ Stack opérationnel — Aucun problème détecté" +elif [ "$ERRORS" -eq 0 ]; then + print_status "$YELLOW" " ⚠ Stack opérationnel — $WARNINGS avertissement(s)" +else + print_status "$RED" " ✗ Stack en erreur — $ERRORS erreur(s), $WARNINGS avertissement(s)" +fi + +print_status "$BLUE" " Dashboard : http://$HOST_IP:8080" +print_status "$BLUE" " tar1090 : http://$HOST_IP:8090" + diff --git a/check_adsb_mistral_v4.sh b/check_adsb_mistral_v4.sh new file mode 100755 index 0000000..58cdb0e --- /dev/null +++ b/check_adsb_mistral_v4.sh @@ -0,0 +1,347 @@ +#!/bin/bash + +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 7.0 (Avec port/IP PostgreSQL et corrections jq) +# Date : 26/06/2026 +# Auteur : JC Abiven (avec Mistral AI) +# ============================================= + +# Vérifier que jq est installé +if ! command -v jq &> /dev/null; then + echo -e "\033[0;31m✗ jq n'est pas installé. Installez-le avec : sudo apt install jq\033[0m" + exit 1 +fi + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Fonction pour afficher un message avec une couleur +print_status() { + echo -e "${1}${2}${NC}" +} + +# Fonction pour afficher un titre +print_title() { + echo -e "\n${BLUE}═══ ${1} ═══${NC}" +} + +# Fonction pour afficher une section +print_section() { + echo -e "\n${CYAN}═══ ${1} ═══${NC}" +} + +# Récupérer l'IP de l'hôte +HOST_IP=$(hostname -I | awk '{print $1}') + +# Afficher l'en-tête +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date "+%a %d/%m/%Y %H:%M:%S") ║" +echo -e "╚══════════════════════════════════════════════════════╝" +print_status "$YELLOW" " → IP détectée : $HOST_IP" + +# ============================================= +# 1. Vérification des conteneurs Docker +# ============================================= +print_section "1. Conteneurs Docker" + +DOCKER_CONTAINERS=("adsb-readsb" "adsb-tar1090" "adsb-adsb2pg" "adsb-webapp" "wordpress-postgres") +for container in "${DOCKER_CONTAINERS[@]}"; do + if docker ps | grep -q "$container"; then + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + if [ "$STATUS" = "healthy" ]; then + print_status "$GREEN" " ✓ $container [healthy]" + else + print_status "$GREEN" " ✓ $container" + fi + else + print_status "$RED" " ✗ $container — non démarré" + fi +done + +# ============================================= +# 2. Vérification du dongle RTL-SDR +# ============================================= +print_section "2. Dongle RTL-SDR" + +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" " ✓ AirNav FlightStick SN:00000010 détecté" + GAIN=$(docker logs adsb-readsb 2>&1 | grep -oP "tuner gain set to \K[0-9.]+ dB" | tail -1) + print_status "$GREEN" " → Gain actuel : $GAIN" +else + print_status "$RED" " ✗ Dongle RTL-SDR non détecté" +fi + +# Vérifier le symlink /dev/adsb_dongle +if [ -L "/dev/adsb_dongle" ]; then + print_status "$GREEN" " ✓ Symlink /dev/adsb_dongle présent" +else + print_status "$YELLOW" " ⚠ Symlink /dev/adsb_dongle introuvable" +fi + +# ============================================= +# 3. Vérification des données ADS-B +# ============================================= +print_section "3. Données ADS-B" + +# Vérifier tar1090 +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + TAR1090_STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/stats.json 2>/dev/null) + if [ -n "$TAR1090_STATS" ]; then + MESSAGES=$(echo "$TAR1090_STATS" | jq -r '.totalMessages // "N/A"') + print_status "$GREEN" " ✓ tar1090 actif — $MESSAGES messages décodés" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les stats de tar1090" + fi + + AIRCRAFT_DATA=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json 2>/dev/null) + if [ -n "$AIRCRAFT_DATA" ]; then + AIRCRAFT_COUNT=$(echo "$AIRCRAFT_DATA" | jq '. | length // 0') + # Correction : utiliser `select` pour éviter l'erreur jq + AIRCRAFT_WITH_POS=$(echo "$AIRCRAFT_DATA" | jq '[.[] | select(.lat != null and .lon != null)] | length // 0') + print_status "$GREEN" " ✓ $AIRCRAFT_COUNT avion(s) en vue dont $AIRCRAFT_WITH_POS avec position GPS" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les données des avions" + fi +else + print_status "$RED" " ✗ tar1090 non accessible" +fi + +# Vérifier le volume readsb-run +if docker exec adsb-readsb ls /run/readsb/aircraft.pb >/dev/null 2>&1; then + print_status "$GREEN" " ✓ Volume readsb-run : aircraft.pb présent" +else + print_status "$YELLOW" " ⚠ Volume readsb-run : aircraft.pb introuvable" +fi + +# ============================================= +# 4. Vérification des ports réseau (avec adresses IP) +# ============================================= +print_section "4. Ports réseau" + +# Fonction pour obtenir l'adresse IP et le port d'écoute +get_listening_info() { + local port=$1 + # Essayer avec ss d'abord + if command -v ss &> /dev/null; then + ss -tulnp | grep ":$port " | awk '{print $5}' | head -1 + else + # Fallback sur netstat + netstat -tulnp 2>/dev/null | grep ":$port " | awk '{print $4}' | head -1 + fi +} + +# Liste des ports à vérifier : format "port:service:ip_attendue" +PORTS=( + "8080:webapp PHP:127.0.0.1" + "8090:tar1090 nginx:0.0.0.0" + "30005:Beast TCP readsb:0.0.0.0" + "5432:PostgreSQL:127.0.0.1" + "30002:RAW TCP:127.0.0.1" + "30003:SBS TCP:127.0.0.1" +) + +for port_info in "${PORTS[@]}"; do + PORT=${port_info%%:*} + SERVICE=${port_info#*:} + SERVICE=${SERVICE%:*} + EXPECTED_IP=${port_info##*:} + + # Obtenir l'IP et le port réel + LISTENING_INFO=$(get_listening_info "$PORT" || echo "inconnu") + ACTUAL_IP=$(echo "$LISTENING_INFO" | cut -d: -f1) + ACTUAL_PORT=$(echo "$LISTENING_INFO" | cut -d: -f2) + + if timeout 2 nc -z "$EXPECTED_IP" "$PORT" 2>/dev/null; then + print_status "$GREEN" " ✓ Port $PORT ($SERVICE) — IP: ${ACTUAL_IP:-inconnu}:${ACTUAL_PORT:-$PORT} (attendu: $EXPECTED_IP:$PORT)" + else + print_status "$RED" " ✗ Port $PORT ($SERVICE) — IP: ${ACTUAL_IP:-inconnu}:${ACTUAL_PORT:-$PORT} (attendu: $EXPECTED_IP:$PORT)" + fi +done + +# Vérifier la connectivité Beast TCP via la gateway +if timeout 2 nc -z 172.20.0.1 30005 2>/dev/null; then + print_status "$GREEN" " ✓ readsb attaché à adsb-net : 172.20.0.2 (gateway: 172.20.0.1)" +else + print_status "$RED" " ✗ Problème de connectivité avec adsb-net (172.20.0.1:30005)" +fi + +# ============================================= +# 5. Vérification de PostgreSQL et du schéma adsb +# ============================================= +print_section "5. PostgreSQL — schéma adsb" + +# Vérifier l'accès à PostgreSQL +if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres 2>/dev/null | grep -q "accepting connections"; then + print_status "$GREEN" " ✓ PostgreSQL accessible" + PG_VERSION=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT version();" 2>/dev/null | head -1 | tr -d ' ') + print_status "$GREEN" " → $PG_VERSION" + + # Obtenir le port et l'IP d'écoute de PostgreSQL + PG_LISTEN_INFO=$(docker exec wordpress-postgres ss -tulnp 2>/dev/null | grep "5432" | awk '{print $5}' | head -1 || echo "inconnu") + PG_ACTUAL_IP=$(echo "$PG_LISTEN_INFO" | cut -d: -f1) + PG_ACTUAL_PORT=$(echo "$PG_LISTEN_INFO" | cut -d: -f2) + print_status "$GREEN" " → Écoute sur : ${PG_ACTUAL_IP:-127.0.0.1}:${PG_ACTUAL_PORT:-5432}" +else + print_status "$RED" " ✗ PostgreSQL non accessible" +fi + +# Vérifier les partitions +PARTITIONS_POSITIONS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | tr -d ' ') +PARTITIONS_HISTORY=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $PARTITIONS_POSITIONS sous-partitions adsb.positions" +print_status "$GREEN" " ✓ $PARTITIONS_HISTORY sous-partitions adsb.aircraft_history" + +# Vérifier les insertions récentes +POSITIONS_5MIN=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $POSITIONS_5MIN positions insérées dans les 5 dernières minutes" + +# Vérifier le remplissage de aircraft_desc +DESC_FILL=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT ROUND(COUNT(aircraft_desc)*100.0/COUNT(*),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ aircraft_desc rempli à ${DESC_FILL:-0}% (30 dernières min)" + +# Taille du schéma adsb +SCHEMA_SIZE=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT pg_size_pretty(pg_total_relation_size('adsb'));" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " → Taille totale schéma adsb : ${SCHEMA_SIZE:-inconnu}" + +# ============================================= +# 6. Vérification de adsb2pg (collecteur) +# ============================================= +print_section "6. adsb2pg — collecteur" + +if docker ps | grep -q "adsb-adsb2pg"; then + ADSB2PG_LOGS=$(docker logs adsb-adsb2pg 2>&1 | tail -1) + if echo "$ADSB2PG_LOGS" | grep -q "positions insérées"; then + print_status "$GREEN" " ✓ adsb2pg actif : $ADSB2PG_LOGS" + else + print_status "$YELLOW" " ⚠ adsb2pg actif mais aucun log récent" + fi + + # Vérifier la dernière insertion (en secondes) + LAST_INSERT_SEC=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at))) FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" 2>/dev/null | tr -d ' ') + if [ -n "$LAST_INSERT_SEC" ]; then + LAST_INSERT_SEC_INT=${LAST_INSERT_SEC%.*} # Supprimer la partie décimale + if [ "$LAST_INSERT_SEC_INT" -le 10 ] 2>/dev/null; then + print_status "$GREEN" " ✓ Dernière insertion il y a ${LAST_INSERT_SEC_INT}s (cycle 5s actif)" + else + print_status "$RED" " ✗ Dernière insertion trop ancienne (${LAST_INSERT_SEC_INT}s)" + fi + else + print_status "$RED" " ✗ Impossible de vérifier la dernière insertion" + fi +else + print_status "$RED" " ✗ adsb2pg non démarré" +fi + +# ============================================= +# 7. Vérification de la webapp +# ============================================= +print_section "7. Webapp — interface utilisateur" + +# Vérifier les endpoints API +API_ENDPOINTS=("action=live" "action=kpi" "action=trajectories") +for endpoint in "${API_ENDPOINTS[@]}"; do + if curl -s --max-time 5 "http://127.0.0.1:8080/api.php?$endpoint" >/dev/null; then + print_status "$GREEN" " ✓ api.php?$endpoint : OK (IP: 127.0.0.1:8080)" + else + print_status "$RED" " ✗ api.php?$endpoint : KO (IP: 127.0.0.1:8080)" + fi +done + +# Vérifier le nombre d'onglets dans index.php +ONGETS=$(curl -s --max-time 5 http://127.0.0.1:8080/index.php | grep -o "
  • " | wc -l) +print_status "$GREEN" " ✓ $ONGETS onglet(s) dans index.php (IP: 127.0.0.1:8080)" + +# Vérifier la taille de app.js +APP_JS_SIZE=$(curl -s --max-time 5 http://127.0.0.1:8080/app.js | wc -c) +print_status "$GREEN" " → app.js : $APP_JS_SIZE octets (IP: 127.0.0.1:8080)" + +# Vérifier l'accès LAN +if curl -s --max-time 5 "http://$HOST_IP:8080" >/dev/null; then + print_status "$GREEN" " ✓ Accès LAN http://$HOST_IP:8080 : HTTP 200 (IP: $HOST_IP:8080)" +else + print_status "$RED" " ✗ Accès LAN http://$HOST_IP:8080 : KO (IP: $HOST_IP:8080)" +fi + +# ============================================= +# 8. Vérification de l'autogain +# ============================================= +print_section "8. Autogain" + +GAIN_STATUS=$(docker logs adsb-readsb 2>&1 | grep -oP "Gain: \K[0-9.]+ dB" | tail -1) +if [ -z "$GAIN_STATUS" ]; then + GAIN_STATUS="inconnu" +fi +print_status "$GREEN" " ✓ Gain courant : $GAIN_STATUS" + +# Vérifier les cycles avec messages insuffisants +INSUFFICIENT_CYCLES=$(docker logs adsb-readsb 2>&1 | grep -c "messages insuffisants" || echo "0") +if [ "$INSUFFICIENT_CYCLES" -gt 0 ] 2>/dev/null; then + print_status "$YELLOW" " ⚠ $INSUFFICIENT_CYCLES cycle(s) avec messages insuffisants (antenne/portée ?)" +else + print_status "$GREEN" " ✓ Aucun cycle avec messages insuffisants" +fi + +# ============================================= +# 9. Vérification des statistiques readsb +# ============================================= +print_section "9. Statistiques readsb" + +# Essayer d'abord via l'IP du conteneur dans adsb-net +STATS=$(curl -s --max-time 5 http://172.20.0.2:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (IP: 172.20.0.2:30003) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + # Essayer via docker exec + STATS=$(docker exec adsb-readsb curl -s http://localhost:30003/readsb/stats.json 2>/dev/null) + if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (via docker exec, IP: localhost:30003) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" + else + print_status "$RED" " ✗ Impossible de récupérer les statistiques de readsb (IP: 172.20.0.2 ou localhost:30003)" + fi +fi + +# ============================================= +# Résumé final +# ============================================= +print_title "RÉSUMÉ" + +# Compter les erreurs et avertissements dans le script actuel +ERRORS=0 +WARNINGS=0 +SCRIPT_OUTPUT=$(./check_adsb_complete_final.sh 2>&1) +while IFS= read -r line; do + if [[ "$line" == *"$RED✗"* ]]; then + ((ERRORS++)) + elif [[ "$line" == *"$YELLOW⚠"* ]]; then + ((WARNINGS++)) + fi +done <<< "$SCRIPT_OUTPUT" + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + print_status "$GREEN" " ✓ Stack opérationnel — Aucun problème détecté" +elif [ "$ERRORS" -eq 0 ]; then + print_status "$YELLOW" " ⚠ Stack opérationnel — $WARNINGS avertissement(s)" +else + print_status "$RED" " ✗ Stack en erreur — $ERRORS erreur(s), $WARNINGS avertissement(s)" +fi + +print_status "$BLUE" " Dashboard : http://$HOST_IP:8080" +print_status "$BLUE" " tar1090 : http://$HOST_IP:8090" + + diff --git a/check_adsb_mistral_v5.sh b/check_adsb_mistral_v5.sh new file mode 100755 index 0000000..8d5b2d8 --- /dev/null +++ b/check_adsb_mistral_v5.sh @@ -0,0 +1,350 @@ +#!/bin/bash + +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 6.0 (7 bugs corrigés) +# Date : 26/06/2026 +# Auteur : JC Abiven (avec Mistral AI) +# ============================================= + +# Vérifier que jq est installé +if ! command -v jq &> /dev/null; then + echo -e "\033[0;31m✗ jq n'est pas installé. Installez-le avec : sudo apt install jq\033[0m" + exit 1 +fi + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Fonction pour afficher un message avec une couleur +print_status() { + echo -e "${1}${2}${NC}" +} + +# Fonction pour afficher un titre +print_title() { + echo -e "\n${BLUE}═══ ${1} ═══${NC}" +} + +# Fonction pour afficher une section +print_section() { + echo -e "\n${CYAN}═══ ${1} ═══${NC}" +} + +# Récupérer l'IP de l'hôte +HOST_IP=$(hostname -I | awk '{print $1}') + +# Afficher l'en-tête +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date "+%a %d/%m/%Y %H:%M:%S") ║" +echo -e "╚══════════════════════════════════════════════════════╝" +print_status "$YELLOW" " → IP détectée : $HOST_IP" + +# ============================================= +# 1. Vérification des conteneurs Docker +# ============================================= +print_section "1. Conteneurs Docker" + +DOCKER_CONTAINERS=("adsb-readsb" "adsb-tar1090" "adsb-adsb2pg" "adsb-webapp" "wordpress-postgres") +for container in "${DOCKER_CONTAINERS[@]}"; do + if docker ps | grep -q "$container"; then + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + if [ "$STATUS" = "healthy" ]; then + print_status "$GREEN" " ✓ $container [healthy]" + else + print_status "$GREEN" " ✓ $container" + fi + else + print_status "$RED" " ✗ $container — non démarré" + fi +done + +# ============================================= +# 2. Vérification du dongle RTL-SDR +# ============================================= +print_section "2. Dongle RTL-SDR" + +if docker logs adsb-readsb 2>&1 | grep -q "rtlsdr: using device #0: Generic RTL2832U OEM (AIRNAV, ADSB_1090, SN 00000010)"; then + print_status "$GREEN" " ✓ AirNav FlightStick SN:00000010 détecté" + # Correction : Pattern grep pour extraire le gain (ex: "49.6 dB") + GAIN=$(docker logs adsb-readsb 2>&1 | grep -oP '\d+\.\d+ dB' | tail -1 || echo "inconnu") + print_status "$GREEN" " → Gain actuel : $GAIN" +else + print_status "$RED" " ✗ Dongle RTL-SDR non détecté" +fi + +# Vérifier le symlink /dev/adsb_dongle +if [ -L "/dev/adsb_dongle" ]; then + print_status "$GREEN" " ✓ Symlink /dev/adsb_dongle présent" +else + print_status "$YELLOW" " ⚠ Symlink /dev/adsb_dongle introuvable" +fi + +# ============================================= +# 3. Vérification des données ADS-B +# ============================================= +print_section "3. Données ADS-B" + +# Vérifier tar1090 +if curl -s --max-time 5 http://127.0.0.1:8090 >/dev/null; then + # Correction : Utiliser .messages au lieu de .totalMessages (champ réel dans tar1090) + TAR1090_STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/stats.json 2>/dev/null) + if [ -n "$TAR1090_STATS" ]; then + MESSAGES=$(echo "$TAR1090_STATS" | jq -r '.messages // "N/A"') + print_status "$GREEN" " ✓ tar1090 actif — $MESSAGES messages décodés" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les stats de tar1090" + fi + + AIRCRAFT_DATA=$(curl -s --max-time 5 http://127.0.0.1:8090/data/aircraft.json 2>/dev/null) + if [ -n "$AIRCRAFT_DATA" ]; then + # Correction : Utiliser .aircraft[] pour itérer sur les avions (pas sur tout le JSON) + AIRCRAFT_COUNT=$(echo "$AIRCRAFT_DATA" | jq '.aircraft | length // 0') + # Correction : Filtre valide pour .lat et .lon (évite l'erreur "Cannot index number with string") + AIRCRAFT_WITH_POS=$(echo "$AIRCRAFT_DATA" | jq '[.aircraft[] | select(.lat != null and .lon != null)] | length // 0') + print_status "$GREEN" " ✓ $AIRCRAFT_COUNT avion(s) en vue dont $AIRCRAFT_WITH_POS avec position GPS" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les données des avions" + fi +else + print_status "$RED" " ✗ tar1090 non accessible" +fi + +# Vérifier le volume readsb-run +if docker exec adsb-readsb ls /run/readsb/aircraft.pb >/dev/null 2>&1; then + print_status "$GREEN" " ✓ Volume readsb-run : aircraft.pb présent" +else + print_status "$YELLOW" " ⚠ Volume readsb-run : aircraft.pb introuvable" +fi + +# ============================================= +# 4. Vérification des ports réseau (avec adresses IP) +# ============================================= +print_section "4. Ports réseau" + +# Fonction pour obtenir l'adresse IP et le port d'écoute +get_listening_info() { + local port=$1 + if command -v ss &> /dev/null; then + ss -tulnp | grep -E ":$port\s" | awk '{print $5}' | head -1 + else + netstat -tulnp 2>/dev/null | grep -E ":$port\s" | awk '{print $4}' | head -1 + fi +} + +# Liste des ports à vérifier : format "port:service:ip_attendue" +PORTS=( + "8080:webapp PHP:127.0.0.1" + "8090:tar1090 nginx:0.0.0.0" + "30005:Beast TCP readsb:0.0.0.0" + "5432:PostgreSQL:127.0.0.1" + "30002:RAW TCP:127.0.0.1" + "30003:SBS TCP:127.0.0.1" +) + +for port_info in "${PORTS[@]}"; do + PORT=${port_info%%:*} + SERVICE=${port_info#*:} + SERVICE=${SERVICE%:*} + EXPECTED_IP=${port_info##*:} + + LISTENING_INFO=$(get_listening_info "$PORT" || echo "inconnu") + ACTUAL_IP=$(echo "$LISTENING_INFO" | cut -d: -f1) + ACTUAL_PORT=$(echo "$LISTENING_INFO" | cut -d: -f2) + + if timeout 2 nc -z "$EXPECTED_IP" "$PORT" 2>/dev/null; then + print_status "$GREEN" " ✓ Port $PORT ($SERVICE) — IP: ${ACTUAL_IP:-*}:${ACTUAL_PORT:-$PORT} (attendu: $EXPECTED_IP:$PORT)" + else + print_status "$RED" " ✗ Port $PORT ($SERVICE) — IP: ${ACTUAL_IP:-*}:${ACTUAL_PORT:-$PORT} (attendu: $EXPECTED_IP:$PORT)" + fi +done + +# Vérifier la connectivité Beast TCP via la gateway +if timeout 2 nc -z 172.20.0.1 30005 2>/dev/null; then + print_status "$GREEN" " ✓ readsb attaché à adsb-net : 172.20.0.2 (gateway: 172.20.0.1)" +else + print_status "$RED" " ✗ Problème de connectivité avec adsb-net (172.20.0.1:30005)" +fi + +# ============================================= +# 5. Vérification de PostgreSQL et du schéma adsb +# ============================================= +print_section "5. PostgreSQL — schéma adsb" + +# Vérifier l'accès à PostgreSQL +if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres 2>/dev/null | grep -q "accepting connections"; then + print_status "$GREEN" " ✓ PostgreSQL accessible" + PG_VERSION=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT version();" 2>/dev/null | head -1 | tr -d ' ') + print_status "$GREEN" " → $PG_VERSION" + + # Obtenir le port et l'IP d'écoute de PostgreSQL + PG_LISTEN_INFO=$(docker exec wordpress-postgres ss -tulnp 2>/dev/null | grep -E ":5432\s" | awk '{print $5}' | head -1 || echo "127.0.0.1:5432") + PG_ACTUAL_IP=$(echo "$PG_LISTEN_INFO" | cut -d: -f1) + PG_ACTUAL_PORT=$(echo "$PG_LISTEN_INFO" | cut -d: -f2) + print_status "$GREEN" " → Écoute sur : ${PG_ACTUAL_IP}:${PG_ACTUAL_PORT}" + + # Correction : Utiliser pg_class + pg_namespace pour obtenir la taille du schéma + SCHEMA_SIZE=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c " + 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 | tr -d ' ') + print_status "$GREEN" " → Taille totale schéma adsb : ${SCHEMA_SIZE:-inconnu}" +else + print_status "$RED" " ✗ PostgreSQL non accessible" +fi + +# Vérifier les partitions +PARTITIONS_POSITIONS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | tr -d ' ') +PARTITIONS_HISTORY=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $PARTITIONS_POSITIONS sous-partitions adsb.positions" +print_status "$GREEN" " ✓ $PARTITIONS_HISTORY sous-partitions adsb.aircraft_history" + +# Vérifier les insertions récentes +POSITIONS_5MIN=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT COUNT(*) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '5 minutes';" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ $POSITIONS_5MIN positions insérées dans les 5 dernières minutes" + +# Vérifier le remplissage de aircraft_desc +DESC_FILL=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT ROUND(COUNT(aircraft_desc)*100.0/COUNT(*),1) FROM adsb.positions WHERE ts >= NOW() - INTERVAL '30 minutes' AND lat IS NOT NULL;" 2>/dev/null | tr -d ' ') +print_status "$GREEN" " ✓ aircraft_desc rempli à ${DESC_FILL:-0}% (30 dernières min)" + +# ============================================= +# 6. Vérification de adsb2pg (collecteur) +# ============================================= +print_section "6. adsb2pg — collecteur" + +if docker ps | grep -q "adsb-adsb2pg"; then + ADSB2PG_LOGS=$(docker logs adsb-adsb2pg 2>&1 | tail -1) + if echo "$ADSB2PG_LOGS" | grep -q "positions insérées"; then + print_status "$GREEN" " ✓ adsb2pg actif : $ADSB2PG_LOGS" + else + print_status "$YELLOW" " ⚠ adsb2pg actif mais aucun log récent" + fi + + # Vérifier la dernière insertion (en secondes) + LAST_INSERT_SEC=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -t -c "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at))) FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" 2>/dev/null | tr -d ' ') + if [ -n "$LAST_INSERT_SEC" ]; then + LAST_INSERT_SEC_INT=${LAST_INSERT_SEC%.*} # Supprimer la partie décimale + if [ "$LAST_INSERT_SEC_INT" -le 10 ] 2>/dev/null; then + print_status "$GREEN" " ✓ Dernière insertion il y a ${LAST_INSERT_SEC_INT}s (cycle 5s actif)" + else + print_status "$RED" " ✗ Dernière insertion trop ancienne (${LAST_INSERT_SEC_INT}s)" + fi + else + print_status "$RED" " ✗ Impossible de vérifier la dernière insertion" + fi +else + print_status "$RED" " ✗ adsb2pg non démarré" +fi + +# ============================================= +# 7. Vérification de la webapp +# ============================================= +print_section "7. Webapp — interface utilisateur" + +# Vérifier les endpoints API +API_ENDPOINTS=("action=live" "action=kpi" "action=trajectories") +for endpoint in "${API_ENDPOINTS[@]}"; do + if curl -s --max-time 5 "http://127.0.0.1:8080/api.php?$endpoint" >/dev/null; then + print_status "$GREEN" " ✓ api.php?$endpoint : OK (IP: 127.0.0.1:8080)" + else + print_status "$RED" " ✗ api.php?$endpoint : KO (IP: 127.0.0.1:8080)" + fi +done + +# Correction : Compter les
  • directement dans le conteneur webapp (pas via curl) +ONGETS=$(docker exec adsb-webapp grep -c "
  • " /var/www/html/index.php 2>/dev/null || echo "0") +print_status "$GREEN" " ✓ $ONGETS onglet(s) dans index.php (IP: 127.0.0.1:8080)" + +# Correction : Taille de app.js directement dans le conteneur +APP_JS_SIZE=$(docker exec adsb-webapp wc -c < /var/www/html/app.js 2>/dev/null | awk '{print $1}' || echo "0") +print_status "$GREEN" " → app.js : $APP_JS_SIZE octets (IP: 127.0.0.1:8080)" + +# Vérifier l'accès LAN +if curl -s --max-time 5 "http://$HOST_IP:8080" >/dev/null; then + print_status "$GREEN" " ✓ Accès LAN http://$HOST_IP:8080 : HTTP 200 (IP: $HOST_IP:8080)" +else + print_status "$RED" " ✗ Accès LAN http://$HOST_IP:8080 : KO (IP: $HOST_IP:8080)" +fi + +# ============================================= +# 8. Vérification de l'autogain +# ============================================= +print_section "8. Autogain" + +# Correction : Pattern grep pour extraire le gain (ex: "49.6 dB") +GAIN_STATUS=$(docker logs adsb-readsb 2>&1 | grep -oP '\d+\.\d+ dB' | tail -1 || echo "inconnu") +print_status "$GREEN" " ✓ Gain courant : $GAIN_STATUS" + +# Vérifier les cycles avec messages insuffisants +INSUFFICIENT_CYCLES=$(docker logs adsb-readsb 2>&1 | grep -c "messages insuffisants" || echo "0") +if [ "$INSUFFICIENT_CYCLES" -gt 0 ] 2>/dev/null; then + print_status "$YELLOW" " ⚠ $INSUFFICIENT_CYCLES cycle(s) avec messages insuffisants (antenne/portée ?)" +else + print_status "$GREEN" " ✓ Aucun cycle avec messages insuffisants" +fi + +# ============================================= +# 9. Statistiques readsb +# ============================================= +print_section "9. Statistiques readsb" + +# Correction : Endpoint correct pour les stats readsb (via tar1090 ou lighttpd interne) +STATS=$(curl -s --max-time 5 http://127.0.0.1:8090/data/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (IP: 127.0.0.1:8090) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + # Fallback : Essayer via lighttpd interne (port 80 dans le conteneur readsb) + STATS=$(docker exec adsb-readsb curl -s http://localhost/readsb/stats.json 2>/dev/null) + if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" " ✓ Statistiques readsb accessibles (via lighttpd, IP: localhost:80) :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" + else + print_status "$YELLOW" " ⚠ Impossible de récupérer les statistiques de readsb (essayez : docker exec adsb-readsb curl -s http://localhost/readsb/stats.json | jq)" + fi +fi + +# ============================================= +# Résumé final +# ============================================= +print_title "RÉSUMÉ" + +# Compter les erreurs et avertissements dans le script actuel +ERRORS=0 +WARNINGS=0 +SCRIPT_OUTPUT=$(mktemp) +./check_adsb_v6_fixed.sh > "$SCRIPT_OUTPUT" 2>&1 +while IFS= read -r line; do + if [[ "$line" == *"$RED✗"* ]]; then + ((ERRORS++)) + elif [[ "$line" == *"$YELLOW⚠"* ]]; then + ((WARNINGS++)) + fi +done < "$SCRIPT_OUTPUT" +rm "$SCRIPT_OUTPUT" + +if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then + print_status "$GREEN" " ✓ Stack opérationnel — Aucun problème détecté" +elif [ "$ERRORS" -eq 0 ]; then + print_status "$YELLOW" " ⚠ Stack opérationnel — $WARNINGS avertissement(s)" +else + print_status "$RED" " ✗ Stack en erreur — $ERRORS erreur(s), $WARNINGS avertissement(s)" +fi + +print_status "$BLUE" " Dashboard : http://$HOST_IP:8080" +print_status "$BLUE" " tar1090 : http://$HOST_IP:8090" + diff --git a/check_adsb_mistral_v5_ByClaude.sh b/check_adsb_mistral_v5_ByClaude.sh new file mode 100755 index 0000000..e2e10e4 --- /dev/null +++ b/check_adsb_mistral_v5_ByClaude.sh @@ -0,0 +1,281 @@ +#!/bin/bash +# ============================================= +# Script de vérification complète de la stack ADS-B +# Version : 9.0 (Corrigé par Claude) +# Date : 26/06/2026 +# Auteur : JC Abiven +# ============================================= + +if ! command -v jq &> /dev/null; then + echo -e "\033[0;31m✗ jq n'est pas installé : sudo apt install jq\033[0m" + exit 1 +fi + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; MAGENTA='\033[0;35m'; CYAN='\033[0;36m'; NC='\033[0m' + +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"; } +sec() { echo -e "\n${CYAN}═══ $1 ═══${NC}"; } + +ERRORS=0; WARNS=0 +HOST_IP=$(ip -4 addr show wlp58s0 2>/dev/null | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+' | head -1) +[ -z "$HOST_IP" ] && HOST_IP=$(hostname -I | awk '{print $1}') + +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ Stack ADS-B Linux-25 — Vérification complète ║" +echo -e "║ $(date '+%a %d/%m/%Y %H:%M:%S') ║" +echo -e "╚══════════════════════════════════════════════════════╝${NC}" +info "IP détectée : $HOST_IP" + +# ── 1. Conteneurs ───────────────────────────────────────────────────────────── +sec "1. Conteneurs Docker" +for ctn in adsb-readsb adsb-tar1090 adsb-adsb2pg adsb-webapp wordpress-postgres; 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) + if [ "$STATUS" = "running" ]; then + LABEL="$ctn" + [ "$HEALTH" = "healthy" ] && LABEL="$ctn [healthy]" + [ "$HEALTH" = "unhealthy" ] && LABEL="$ctn [unhealthy]" && WARNS=$((WARNS+1)) + [ "$HEALTH" = "unhealthy" ] \ + && warn "$LABEL — démarré $UPTIME" \ + || ok "$LABEL — démarré $UPTIME" + else + fail "$ctn — STATUS=${STATUS:-absent}" + fi +done + +# ── 2. Dongle RTL-SDR ───────────────────────────────────────────────────────── +sec "2. Dongle RTL-SDR" +if docker logs adsb-readsb 2>&1 | grep -q "SN 00000010"; then + # BUG CORRIGÉ : grep -oP avec lookbehind correct + GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 \ + | grep -oP '[\d.]+(?= dB)') + ok "AirNav FlightStick SN:00000010 détecté — gain ${GAIN:-?} dB" +else + fail "Dongle SN:00000010 non détecté" +fi +[ -L "/dev/adsb_dongle" ] && ok "Symlink /dev/adsb_dongle présent" \ + || warn "Symlink /dev/adsb_dongle introuvable" + +# ── 3. Données ADS-B ────────────────────────────────────────────────────────── +sec "3. Données ADS-B" +AC_JSON=$(curl -s --max-time 5 "http://127.0.0.1:8090/data/aircraft.json" 2>/dev/null) +if [ -n "$AC_JSON" ]; then + # BUG CORRIGÉ : utiliser .aircraft[] et pas .[] (qui itère aussi sur messages/now) + MSGS=$(echo "$AC_JSON" | jq -r '.messages // 0') + AVIONS=$(echo "$AC_JSON" | jq '[.aircraft // []] | length') + WITH_POS=$(echo "$AC_JSON" | jq '[.aircraft[]? | select(.lat != null and .lon != null)] | length') + [ "${MSGS:-0}" -gt 0 ] \ + && ok "tar1090 actif — $(printf '%d' "$MSGS") messages décodés" \ + || warn "tar1090 répond — 0 message (démarrage récent ?)" + ok "$AVIONS avion(s) en vue dont $WITH_POS avec position GPS" +else + fail "tar1090 ne répond pas sur :8090/data/aircraft.json" + AVIONS=0; WITH_POS=0; MSGS=0 +fi + +# BUG CORRIGÉ : vérifier via docker exec (le volume tmpfs n'est pas accessible hôte) +if docker exec adsb-readsb test -f /run/readsb/aircraft.pb 2>/dev/null; then + ok "Volume readsb-run : aircraft.pb présent dans le conteneur readsb" +elif docker exec adsb-tar1090 test -f /run/readsb/aircraft.pb 2>/dev/null; then + ok "Volume readsb-run : aircraft.pb présent dans le conteneur tar1090" +else + warn "aircraft.pb introuvable dans les conteneurs" +fi + +# ── 4. Ports réseau ─────────────────────────────────────────────────────────── +sec "4. Ports réseau" +declare -A PORT_LABELS=( + ["8080"]="webapp PHP" ["8090"]="tar1090 nginx" + ["30005"]="Beast TCP" ["5432"]="PostgreSQL" + ["30002"]="RAW TCP" ["30003"]="SBS TCP" +) +for port in 8080 8090 30005 5432 30002 30003; do + LISTEN=$(ss -tlnp 2>/dev/null | grep ":${port}[[:space:]]" | awk '{print $4}' | head -1) + if [ -n "$LISTEN" ]; then + ok "Port $port en écoute sur $LISTEN — ${PORT_LABELS[$port]}" + else + fail "Port $port absent — ${PORT_LABELS[$port]}" + fi +done + +# Connexion Beast via gateway adsb-net +READSB_IP=$(docker inspect adsb-readsb 2>/dev/null | python3 -c " +import json,sys +d=json.load(sys.stdin)[0] +for n,v in d['NetworkSettings']['Networks'].items(): + if 'adsb' in n: print(v['IPAddress'])" 2>/dev/null) +if [ -n "$READSB_IP" ] && [ "$READSB_IP" != "None" ]; then + ok "readsb attaché à adsb-net : $READSB_IP (gateway 172.20.0.1)" +else + fail "readsb non attaché à adsb-net — Beast TCP impossible" +fi + +# ── 5. PostgreSQL ───────────────────────────────────────────────────────────── +sec "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 | sed 's/ */ /g' | xargs) +if [ -n "$PG_VER" ]; then + ok "PostgreSQL accessible" + info "${PG_VER:0:70}" +else + fail "PostgreSQL inaccessible" +fi + +# BUG CORRIGÉ : pg_tables plutôt que pg_class pour les partitions feuilles +PART_POS=$($PG "SELECT COUNT(*) FROM pg_tables + WHERE schemaname='adsb' AND tablename LIKE 'positions_%';" 2>/dev/null | xargs) +ok "${PART_POS:-0} sous-partitions adsb.positions" + +PART_HIS=$($PG "SELECT COUNT(*) FROM pg_tables + WHERE schemaname='adsb' AND tablename LIKE 'aircraft_history_%';" 2>/dev/null | xargs) +ok "${PART_HIS:-0} sous-partitions adsb.aircraft_history" + +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}% (30 dernières 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" + +# BUG CORRIGÉ : pg_size_pretty sur le schéma via pg_class +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:-?}" + +# ── 6. adsb2pg ──────────────────────────────────────────────────────────────── +sec "6. adsb2pg — collecteur" +LAST_INS=$(docker logs adsb-adsb2pg 2>&1 | grep "positions insérées" | tail -1) +if [ -n "$LAST_INS" ]; then + ok "adsb2pg actif" + info "$LAST_INS" + # BUG CORRIGÉ : calculer l'âge depuis PostgreSQL (plus fiable que les logs) + LAST_SEC=$($PG "SELECT EXTRACT(EPOCH FROM (NOW() - MAX(inserted_at)))::INT + FROM adsb.positions WHERE inserted_at >= NOW() - INTERVAL '10 minutes';" \ + 2>/dev/null | xargs) + LAST_SEC_INT=${LAST_SEC%.*} + if [ -n "$LAST_SEC_INT" ] && [ "$LAST_SEC_INT" -le 15 ] 2>/dev/null; then + ok "Dernière insertion il y a ${LAST_SEC_INT}s (cycle 5s actif)" + elif [ -n "$LAST_SEC_INT" ]; then + warn "Dernière insertion il y a ${LAST_SEC_INT}s (> 15s)" + fi +else + warn "Aucun log d'insertion trouvé dans adsb2pg" +fi + +# ── 7. Webapp ───────────────────────────────────────────────────────────────── +sec "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) + ERR=$(echo "$RESP" | jq -r '.error // empty' 2>/dev/null) + if echo "$RESP" | jq . >/dev/null 2>&1; then + [ -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 + +# BUG CORRIGÉ : compter les onglets directement dans le conteneur +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" + +# BUG CORRIGÉ : taille app.js directement dans le conteneur +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://${HOST_IP}:8080/" 2>/dev/null) +[ "$HTTP_CODE" = "200" ] \ + && ok "Accès LAN http://${HOST_IP}:8080 : HTTP $HTTP_CODE" \ + || warn "Accès LAN http://${HOST_IP}:8080 : HTTP ${HTTP_CODE:-?}" + +# ── 8. Autogain ─────────────────────────────────────────────────────────────── +sec "8. Autogain" +# BUG CORRIGÉ : pattern grep correct pour le gain +CUR_GAIN=$(docker logs adsb-readsb 2>&1 | grep "tuner gain set to" | tail -1 \ + | grep -oP '[\d.]+(?= dB)') +AG_STATE=$(docker logs adsb-readsb 2>&1 | grep -oP "(?<=state ')[\w]+" | tail -1) +[ -n "$CUR_GAIN" ] \ + && ok "Gain courant : ${CUR_GAIN} dB — état : ${AG_STATE:-inconnu}" \ + || warn "Gain courant inconnu" + +# BUG CORRIGÉ : recherche en anglais (les logs readsb sont en anglais) +INSUF=$(docker logs adsb-readsb 2>&1 | grep -c "Insufficient messages") +[ "$INSUF" -gt 3 ] \ + && warn "$INSUF cycles avec messages insuffisants (antenne/portée ?)" \ + || info "$INSUF cycle(s) avec messages insuffisants" + +# ── 9. Statistiques readsb ──────────────────────────────────────────────────── +sec "9. Statistiques readsb" +# BUG CORRIGÉ : les stats sont sur lighttpd port 8080 interne, pas 30003 +# Le bon endpoint est /data/stats.json via tar1090 +STATS_JSON=$(curl -s --max-time 5 "http://127.0.0.1:8090/data/stats.json" 2>/dev/null) +if [ -n "$STATS_JSON" ] && echo "$STATS_JSON" | jq . >/dev/null 2>&1; then + # Stats last 1 min + MSGS_1MIN=$(echo "$STATS_JSON" | jq -r '.last1min.local.messages_strong_signal // .last1min.local.decoded // 0' 2>/dev/null) + MSGS_TOTAL=$(echo "$STATS_JSON" | jq -r '.total.local.messages // 0' 2>/dev/null) + RANGE=$(echo "$STATS_JSON" | jq -r '.last1min.local.max_distance_meters // 0' 2>/dev/null) + RANGE_KM=$(python3 -c "print(round(${RANGE:-0}/1000,1))" 2>/dev/null) + TRACKS=$(echo "$STATS_JSON" | jq -r '.last1min.local.tracks_with_pos // 0' 2>/dev/null) + ok "Statistiques readsb disponibles (via tar1090 :8090)" + info "Messages totaux : ${MSGS_TOTAL:-0}" + info "Messages dernière minute : ${MSGS_1MIN:-0}" + info "Pistes avec position (1min) : ${TRACKS:-0}" + info "Portée max (1min) : ${RANGE_KM:-0} km" +else + # Fallback : stats depuis lighttpd interne readsb + STATS2=$(docker exec adsb-readsb wget -qO- "http://localhost:8080/data/stats.json" 2>/dev/null) + if [ -n "$STATS2" ] && echo "$STATS2" | jq . >/dev/null 2>&1; then + MSGS_TOTAL=$(echo "$STATS2" | jq -r '.total.local.messages // 0') + ok "Statistiques readsb (via lighttpd interne) — Messages totaux : $MSGS_TOTAL" + else + warn "Statistiques readsb non disponibles" + info "Utiliser : docker exec adsb-readsb cat /run/readsb/stats.pb" + fi +fi + +# Fichiers stats protobuf (toujours disponibles) +STATS_AGE=$(docker exec adsb-readsb stat -c %Y /run/readsb/stats.pb 2>/dev/null) +if [ -n "$STATS_AGE" ]; then + AGE_S=$(( $(date +%s) - STATS_AGE )) + ok "stats.pb dans readsb-run, mis à jour il y a ${AGE_S}s" +fi + +# ── RÉSUMÉ ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${MAGENTA}╔══════════════════════════════════════════════════════╗" +echo -e "║ RÉSUMÉ ║" +echo -e "╠══════════════════════════════════════════════════════╣${NC}" +if [ "$ERRORS" -eq 0 ] && [ "$WARNS" -eq 0 ]; then + echo -e "${MAGENTA}║${NC} ${GREEN}✓ Stack opérationnel — aucune anomalie détectée${NC} ${MAGENTA}║${NC}" +elif [ "$ERRORS" -eq 0 ]; then + echo -e "${MAGENTA}║${NC} ${YELLOW}⚠ Stack opérationnel — $WARNS avertissement(s)${NC} ${MAGENTA}║${NC}" +else + echo -e "${MAGENTA}║${NC} ${RED}✗ $ERRORS erreur(s), $WARNS avertissement(s)${NC} ${MAGENTA}║${NC}" +fi +echo -e "${MAGENTA}║${NC} ${BLUE}Dashboard : http://${HOST_IP}:8080${NC} ${MAGENTA}║${NC}" +echo -e "${MAGENTA}║${NC} ${BLUE}tar1090 : http://${HOST_IP}:8090${NC} ${MAGENTA}║${NC}" +echo -e "${MAGENTA}╚══════════════════════════════════════════════════════╝${NC}" + +exit $ERRORS diff --git a/check_adsb_old.sh b/check_adsb_old.sh new file mode 100755 index 0000000..169cffc --- /dev/null +++ b/check_adsb_old.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +print_status() { + echo -e "${1}${2}${NC}" +} + +# 1. Vérifier que le conteneur readsb est en cours d'exécution +print_status "$YELLOW" "=== Vérification du conteneur readsb ===" +if docker ps | grep -q "adsb-readsb"; then + print_status "$GREEN" "✓ Le conteneur adsb-readsb est en cours d'exécution." +else + print_status "$RED" "✗ Le conteneur adsb-readsb n'est pas en cours d'exécution." + docker ps + exit 1 +fi + +# 2. Vérifier les logs de readsb pour la détection du dongle +print_status "$YELLOW" "\n=== Vérification de la détection du dongle RTL-SDR ===" +if docker logs adsb-readsb 2>&1 | grep -q "RTL2832U\|AIRNAV\|ADSB_1090"; then + print_status "$GREEN" "✓ Le dongle RTL2832U (AirNav FlightStick) est détecté par readsb." +else + print_status "$RED" "✗ Le dongle RTL2832U n'est pas détecté dans les logs." + print_status "$YELLOW" "Dernières lignes des logs :" + docker logs adsb-readsb 2>&1 | tail -20 +fi + +# 3. Vérifier que readsb décode des messages ADS-B +print_status "$YELLOW" "\n=== Vérification du décodage ADS-B ===" +if docker logs adsb-readsb 2>&1 | grep -q "Decoded\|Accepted"; then + print_status "$GREEN" "✓ readsb décode des messages ADS-B." +else + print_status "$RED" "✗ Aucun message ADS-B décodé trouvé dans les logs." + print_status "$YELLOW" "Dernières lignes des logs :" + docker logs adsb-readsb 2>&1 | tail -20 +fi + +# 4. Vérifier que le port 30005 (Beast) émet des données +print_status "$YELLOW" "\n=== Vérification du port 30005 (Beast) ===" +if timeout 5 bash -c "echo > /dev/tcp/127.0.0.1/30005" 2>/dev/null; then + print_status "$GREEN" "✓ Le port 30005 est accessible localement." +else + print_status "$RED" "✗ Le port 30005 n'est pas accessible localement." +fi + +# 5. Vérifier que tar1090 est accessible +print_status "$YELLOW" "\n=== Vérification de tar1090 ===" +if curl -s http://127.0.0.1:8090 >/dev/null; then + print_status "$GREEN" "✓ tar1090 est accessible sur http://127.0.0.1:8090." +else + print_status "$RED" "✗ tar1090 n'est pas accessible sur http://127.0.0.1:8090." +fi + +# 6. Vérifier les statistiques de readsb +print_status "$YELLOW" "\n=== Vérification des statistiques readsb ===" +STATS=$(curl -s http://localhost:30003/readsb/stats.json 2>/dev/null) +if [ -n "$STATS" ]; then + TOTAL_MSG=$(echo "$STATS" | jq -r '.totalMessages // "N/A"') + MSG_PER_SEC=$(echo "$STATS" | jq -r '.messagesPerSecond // "N/A"') + print_status "$GREEN" "✓ Statistiques readsb accessibles :" + print_status "$GREEN" " - Messages totaux : $TOTAL_MSG" + print_status "$GREEN" " - Messages/seconde : $MSG_PER_SEC" +else + print_status "$RED" "✗ Impossible de récupérer les statistiques de readsb." + print_status "$YELLOW" "Vérifiez que le port 30003 est accessible depuis cette machine." +fi + +# 7. Vérifier la présence d'avions sur tar1090 +print_status "$YELLOW" "\n=== Vérification des avions sur tar1090 ===" +AIRCRAFT_COUNT=$(curl -s http://127.0.0.1:8090/data/aircraft.json | jq '. | length // 0') +if [ "$AIRCRAFT_COUNT" -gt 0 ]; then + print_status "$GREEN" "✓ $AIRCRAFT_COUNT avion(s) détecté(s) sur tar1090." +else + print_status "$RED" "✗ Aucun avion détecté sur tar1090 (vérifiez la portée ou l'antenne)." +fi + +print_status "$YELLOW" "\n=== Fin des vérifications ===" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..763abca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,266 @@ +--- +# ═══════════════════════════════════════════════════════════════════════════════ +# Stack ADS-B Linux-25 — 1 à 3 récepteurs, pilotés par profiles Compose +# +# Les services d'un récepteur sont conditionnés au profile rx. La liste des +# profiles actifs et les numéros de série sont générés depuis le matériel +# réellement présent : +# +# /data/adsb/scripts/gen_env.sh +# docker compose --env-file .env.receivers up -d --remove-orphans +# +# Secrets : PG_PASSWORD est lu depuis .env (non versionné), plus en dur ici. +# ═══════════════════════════════════════════════════════════════════════════════ + +x-readsb-common: &readsb-common + image: ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest + restart: unless-stopped + privileged: true + devices: + - /dev/bus/usb:/dev/bus/usb + networks: + - adsb-net + tmpfs: + - /var/log:size=32M + +x-readsb-env: &readsb-env + TZ: Europe/Paris + READSB_DCFILTER: "true" + READSB_DEVICE_TYPE: rtlsdr + READSB_FIX: "true" + READSB_LAT: "48.8236" + READSB_LON: "2.2770" +# READSB_MODEAC: "true" + READSB_RX_LOCATION_ACCURACY: "2" + READSB_STATS_RANGE: "true" + READSB_NET_ENABLE: "true" + +x-tar1090-common: &tar1090-common + image: ghcr.io/sdr-enthusiasts/docker-tar1090:latest + restart: unless-stopped + networks: + - adsb-net + tmpfs: + - /run/tar1090:size=64M + - /var/log:size=32M + +x-tar1090-env: &tar1090-env + TZ: Europe/Paris + LAT: "48.8236" + LONG: "2.2770" + TAR1090_DEFAULTCENTERLAT: "48.8236" + TAR1090_DEFAULTCENTERLON: "2.2770" + TAR1090_DEFAULTZOOM: "10" + TAR1090_PLANECOUNTINTITLE: "true" + TAR1090_ENABLE_AC_DB: "true" + +x-pg-env: &pg-env + PG_HOST: 127.0.0.1 + PG_PORT: "5432" + PG_USER: pgz_admin + PG_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD manquant — le définir dans /data/adsb/.env} + PG_DB: wpz_postgres + PG_SCHEMA: adsb + +services: + + # ═══════════════════════════════════════════════════════════════ + # Services permanents (indépendants du nombre de dongles) + # ═══════════════════════════════════════════════════════════════ + + webapp: + build: ./webapp + container_name: adsb-webapp + restart: unless-stopped + network_mode: host + environment: + <<: *pg-env + TZ: Europe/Paris + TAR1090_URL: http://127.0.0.1:8090 + APP_TITLE: ADS-B Linux-25 + STATION_LAT: "48.8236" + STATION_LON: "2.2770" + + # ═══════════════════════════════════════════════════════════════ + # Slot rx1 — ports 8090 / 30002 / 30003 / 30005 + # ═══════════════════════════════════════════════════════════════ + + readsb: + <<: *readsb-common + container_name: adsb-readsb + profiles: ["rx1"] + environment: + <<: *readsb-env + READSB_RTLSDR_DEVICE: ${RX1_SERIAL} + READSB_GAIN: ${RX1_GAIN:-autogain} + READSB_NET_SBS_OUTPUT_PORT: "30003" + READSB_NET_BEAST_OUTPUT_PORT: "30005" + READSB_NET_RAW_OUTPUT_PORT: "30002" + ports: + - "127.0.0.1:30003:30003" + - "30005:30005" + - "127.0.0.1:30002:30002" + volumes: + - readsb-run:/run/readsb + + tar1090: + <<: *tar1090-common + container_name: adsb-tar1090 + profiles: ["rx1"] + depends_on: + - readsb + ports: + - "8090:8090" + environment: + <<: *tar1090-env + BEASTHOST: readsb + BEASTPORT: "30005" + TAR1090_PAGETITLE: ADS-B Linux-25 + TAR1090_NGINX_PORT: "8090" + volumes: + - readsb-run:/run/readsb + + adsb2pg: + build: ./adsb2pg + container_name: adsb-adsb2pg + profiles: ["rx1"] + restart: unless-stopped + network_mode: host + environment: + <<: *pg-env + TZ: Europe/Paris + DUMP1090_URL: http://127.0.0.1:8090/data/aircraft.json + POLL_INTERVAL: "5" + RECEIVER_SERIAL: ${RX1_SERIAL} + TAR1090_DB_PATH: /tar1090-db + + # ═══════════════════════════════════════════════════════════════ + # Slot rx2 — ports 8091 / 30012 / 30013 / 30015 + # ═══════════════════════════════════════════════════════════════ + + readsb2: + <<: *readsb-common + container_name: adsb-readsb2 + profiles: ["rx2"] + environment: + <<: *readsb-env + READSB_RTLSDR_DEVICE: ${RX2_SERIAL} + READSB_GAIN: ${RX2_GAIN:-autogain} + READSB_NET_SBS_OUTPUT_PORT: "30013" + READSB_NET_BEAST_OUTPUT_PORT: "30015" + READSB_NET_RAW_OUTPUT_PORT: "30012" + ports: + - "127.0.0.1:30013:30013" + - "30015:30015" + - "127.0.0.1:30012:30012" + volumes: + - readsb2-run:/run/readsb + + tar10902: + <<: *tar1090-common + container_name: adsb-tar10902 + profiles: ["rx2"] + depends_on: + - readsb2 + ports: + - "8091:8091" + environment: + <<: *tar1090-env + BEASTHOST: readsb2 + BEASTPORT: "30015" + TAR1090_PAGETITLE: ADS-B Linux-25 (récepteur 2) + TAR1090_NGINX_PORT: "8091" + volumes: + - readsb2-run:/run/readsb + + adsb2pg2: + build: ./adsb2pg + container_name: adsb-adsb2pg2 + profiles: ["rx2"] + restart: unless-stopped + network_mode: host + environment: + <<: *pg-env + TZ: Europe/Paris + DUMP1090_URL: http://127.0.0.1:8091/data/aircraft.json + POLL_INTERVAL: "5" + RECEIVER_SERIAL: ${RX2_SERIAL} + TAR1090_DB_PATH: /tar1090-db + + # ═══════════════════════════════════════════════════════════════ + # Slot rx3 — ports 8092 / 30022 / 30023 / 30025 + # ═══════════════════════════════════════════════════════════════ + + readsb3: + <<: *readsb-common + container_name: adsb-readsb3 + profiles: ["rx3"] + environment: + <<: *readsb-env + READSB_RTLSDR_DEVICE: ${RX3_SERIAL} + READSB_GAIN: ${RX3_GAIN:-autogain} + READSB_NET_SBS_OUTPUT_PORT: "30023" + READSB_NET_BEAST_OUTPUT_PORT: "30025" + READSB_NET_RAW_OUTPUT_PORT: "30022" + ports: + - "127.0.0.1:30023:30023" + - "30025:30025" + - "127.0.0.1:30022:30022" + volumes: + - readsb3-run:/run/readsb + + tar10903: + <<: *tar1090-common + container_name: adsb-tar10903 + profiles: ["rx3"] + depends_on: + - readsb3 + ports: + - "8092:8092" + environment: + <<: *tar1090-env + BEASTHOST: readsb3 + BEASTPORT: "30025" + TAR1090_PAGETITLE: ADS-B Linux-25 (récepteur 3) + TAR1090_NGINX_PORT: "8092" + volumes: + - readsb3-run:/run/readsb + + adsb2pg3: + build: ./adsb2pg + container_name: adsb-adsb2pg3 + profiles: ["rx3"] + restart: unless-stopped + network_mode: host + environment: + <<: *pg-env + TZ: Europe/Paris + DUMP1090_URL: http://127.0.0.1:8092/data/aircraft.json + POLL_INTERVAL: "5" + RECEIVER_SERIAL: ${RX3_SERIAL} + TAR1090_DB_PATH: /tar1090-db + +networks: + adsb-net: + name: adsb-net + driver: bridge + +volumes: + readsb-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=64m + readsb2-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=64m + readsb3-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=64m diff --git a/export_adsb_bundle.sh b/export_adsb_bundle.sh new file mode 100755 index 0000000..922860c --- /dev/null +++ b/export_adsb_bundle.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# export_adsb_bundle.sh — Empaquette le stack ADS-B fonctionnel de Linux-25 +# Version 2.0 — JCA 2026 +# +# À LANCER SUR LINUX-25 (la machine source, celle qui fonctionne déjà). +# Produit une archive .tar.gz portable, prête à copier sur la machine neuve +# et à installer avec install_adsb_stack.sh. +# +# v2.0 (05/07/2026) : inclut les correctifs découverts lors des incidents +# du 03-05/07/2026 : +# - adsb2pg.py : clean_altitude() (bug "ground") + rollback() sur exception +# - watchdog_readsb.sh v2 : seuil adaptatif jour/nuit (évite le flapping +# nocturne sur les creux de trafic réels) +# - schema_adsb_base.sql : schéma générique SANS les partitions datées +# spécifiques à Linux-25 (w25-w28, 2026/2027) — la nouvelle machine +# amorce ses propres partitions à la date réelle d'installation +# - check_adsb.sh v3.4 : watchdog crontab, continuité partitions, RestartCount +# - cron d'automatisation des partitions (dimanche 20h, +14 jours) +# +# Usage : ./export_adsb_bundle.sh [chemin_sortie.tar.gz] +# ═══════════════════════════════════════════════════════════════════════════════ +set -euo pipefail + +ADSB_DIR="/data/adsb" +OUT_FILE="${1:-/tmp/adsb_bundle_$(date +%Y%m%d_%H%M%S).tar.gz}" +WORK_DIR=$(mktemp -d) +BUNDLE_DIR="$WORK_DIR/adsb_bundle" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +ok() { echo -e " ${GREEN}✓${NC} $1"; } +fail() { echo -e " ${RED}✗${NC} $1"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1"; } +info() { echo -e " ${CYAN}→${NC} $1"; } +step() { echo -e "\n${BOLD}▸ $1${NC}"; } + +trap 'rm -rf "$WORK_DIR"' EXIT + +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Export bundle ADS-B — source : $(hostname) ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + +mkdir -p "$BUNDLE_DIR"/{adsb2pg,webapp,scripts,sql,docs} + +# ── 1. docker-compose.yml (nettoyé des secrets) ─────────────────────────────── +step "1. docker-compose.yml" +if [ -f "$ADSB_DIR/docker-compose.yml" ]; then + # Remplace le mot de passe en dur par un placeholder — install_adsb_stack.sh + # en génère un nouveau et l'injecte à l'installation. + sed -E 's/(PG_PASSWORD=)[^[:space:]]+/\1__PG_PASSWORD_PLACEHOLDER__/' \ + "$ADSB_DIR/docker-compose.yml" > "$BUNDLE_DIR/docker-compose.yml" + ok "docker-compose.yml exporté (mot de passe neutralisé)" +else + fail "docker-compose.yml introuvable dans $ADSB_DIR" +fi + +# ── 2. adsb2pg (code source complet) ────────────────────────────────────────── +step "2. adsb2pg" +if [ -d "$ADSB_DIR/adsb2pg" ]; then + # --exclude .git : tar1090-db est un dépôt Git cloné en sous-répertoire — + # son historique n'a aucune utilité dans l'image Docker et alourdit + # inutilement le bundle (souvent plusieurs dizaines de Mo). + if command -v rsync &>/dev/null; then + rsync -a --exclude='.git' "$ADSB_DIR/adsb2pg/" "$BUNDLE_DIR/adsb2pg/" + else + cp -r "$ADSB_DIR/adsb2pg/." "$BUNDLE_DIR/adsb2pg/" + find "$BUNDLE_DIR/adsb2pg" -type d -name ".git" -exec rm -rf {} + 2>/dev/null + fi + GIT_LEFT=$(find "$BUNDLE_DIR/adsb2pg" -type d -name ".git" | wc -l) + if [ "$GIT_LEFT" -eq 0 ]; then + ok "Code adsb2pg copié ($(find "$BUNDLE_DIR/adsb2pg" -name '*.py' | wc -l) fichier(s) .py), .git exclu" + else + warn ".git non exclu proprement — vérifier manuellement la taille du bundle" + fi + if grep -q "clean_altitude" "$BUNDLE_DIR/adsb2pg/adsb2pg.py" 2>/dev/null; then + ok "Correctif clean_altitude() présent" + else + warn "Correctif clean_altitude() ABSENT — bundle basé sur une version non corrigée !" + fi + if grep -q "conn.rollback()" "$BUNDLE_DIR/adsb2pg/adsb2pg.py" 2>/dev/null; then + ok "Correctif rollback() présent" + else + warn "Correctif rollback() ABSENT — bundle basé sur une version non corrigée !" + fi +else + fail "Répertoire adsb2pg introuvable dans $ADSB_DIR" +fi + +# ── 3. webapp (code source complet) ─────────────────────────────────────────── +step "3. webapp" +if [ -d "$ADSB_DIR/webapp" ]; then + cp -r "$ADSB_DIR/webapp/." "$BUNDLE_DIR/webapp/" + ok "Code webapp copié" +else + fail "Répertoire webapp introuvable dans $ADSB_DIR" +fi + +# ── 4. Schéma SQL générique (SANS les partitions datées) ────────────────────── +step "4. Schéma PostgreSQL" +if [ -f "$ADSB_DIR/schema_adsb_base.sql" ]; then + cp "$ADSB_DIR/schema_adsb_base.sql" "$BUNDLE_DIR/sql/schema_adsb_base.sql" + ok "schema_adsb_base.sql copié depuis $ADSB_DIR" +else + warn "schema_adsb_base.sql absent de $ADSB_DIR — utilisation de la copie du bundle courant" + info "Pense à le régénérer si le schéma a changé depuis :" + info " docker exec wordpress-postgres pg_dump -U pgz_admin -d wpz_postgres --schema-only -n adsb > schema_full.sql" + info " puis en retirer les partitions datées (w25-w28, aircraft_history_2026/2027)" +fi + +# ── 5. Scripts opérationnels (watchdog v2, check_adsb v3.4) ─────────────────── +step "5. Scripts opérationnels" +for f in scripts/watchdog_readsb.sh check_adsb.sh; do + SRC="$ADSB_DIR/$f" + DEST="$BUNDLE_DIR/scripts/$(basename "$f")" + if [ -f "$SRC" ]; then + cp "$SRC" "$DEST" + chmod +x "$DEST" + ok "$(basename "$f") copié" + else + warn "$SRC introuvable — à ajouter manuellement au bundle avant install" + fi +done + +# Vérifier que le watchdog copié est bien la v2 (seuil adaptatif) +if grep -q "THRESHOLD" "$BUNDLE_DIR/scripts/watchdog_readsb.sh" 2>/dev/null; then + ok "watchdog_readsb.sh : v2 (seuil adaptatif jour/nuit) confirmée" +else + warn "watchdog_readsb.sh : semble être la v1 (pas de seuil adaptatif) — flapping nocturne possible sur la nouvelle machine" +fi + +# ── 6. udev rule (RTL-SDR) ──────────────────────────────────────────────────── +step "6. Règle udev RTL-SDR" +if [ -f /etc/udev/rules.d/99-adsb-docker.rules ]; then + cp /etc/udev/rules.d/99-adsb-docker.rules "$BUNDLE_DIR/scripts/99-adsb-docker.rules" + ok "Règle udev copiée" +else + warn "Règle udev absente sur cette machine — la nouvelle machine n'aura pas de relance auto USB" +fi + +# ── 7. Documentation / notes de version ─────────────────────────────────────── +step "7. Documentation" +cat > "$BUNDLE_DIR/docs/CHANGELOG_BUNDLE.md" << 'EOF' +# Bundle ADS-B — historique des correctifs inclus + +## v2.0 (05/07/2026) +- **adsb2pg.py** : fix `clean_altitude()` — readsb/tar1090 renvoient la chaîne + littérale `"ground"` pour l'altitude d'un avion au sol, ce qui plantait + l'INSERT PostgreSQL (colonne integer). Sans `conn.rollback()` derrière, + cette unique erreur bloquait tout le collecteur pendant 13h (incident du + 03/07/2026 sur Linux-25). +- **watchdog_readsb.sh v2** : seuil adaptatif jour (1 cycle/5min) / nuit + (3 cycles/15min) — la v1 redémarrait le dongle sur de simples creux de + trafic nocturne, confondus avec un vrai gel matériel. +- **schema_adsb_base.sql** : schéma généré à partir d'un pg_dump --schema-only + de Linux-25, DÉLIBÉRÉMENT expurgé des partitions datées (semaines/années + spécifiques à Linux-25). L'installeur amorce les partitions à la date + réelle d'installation via `create_week_partition()`/`create_year_partition()`. +- **check_adsb.sh v3.4** : ajout des vérifications watchdog (crontab, fraîcheur, + flapping), continuité des partitions, RestartCount Docker, sync NTP. +- Automatisation cron de création des partitions (dimanche 20h, +14 jours). + +## Notes de déploiement +- Le récepteur RTL-SDR de la nouvelle machine aura un numéro de série USB + différent — install_adsb_stack.sh le détecte automatiquement et met à jour + RECEIVER_SERIAL dans docker-compose.yml + une ligne dans adsb.receivers. +- Le mot de passe PostgreSQL est régénéré à l'installation, jamais réutilisé + tel quel entre machines. +EOF +ok "CHANGELOG_BUNDLE.md généré" + +# ── 8. Archivage ─────────────────────────────────────────────────────────────── +step "8. Création de l'archive" +tar czf "$OUT_FILE" -C "$WORK_DIR" adsb_bundle +SIZE=$(du -sh "$OUT_FILE" | cut -f1) +ok "Archive créée : $OUT_FILE ($SIZE)" + +echo -e "\n${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Bundle prêt : $OUT_FILE" +echo -e "${BOLD}║ Copie sur la nouvelle machine puis lance : ║${NC}" +echo -e "${BOLD}║ tar xzf $(basename "$OUT_FILE") ║${NC}" +echo -e "${BOLD}║ cd adsb_bundle && sudo ./install_adsb_stack.sh ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" diff --git a/fix_permissions.sh b/fix_permissions.sh new file mode 100755 index 0000000..5e2fc3e --- /dev/null +++ b/fix_permissions.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# ============================================================================ +# Correction permissions UID après bascule postgres:16-alpine -> postgis/postgis:16-3.5 +# Le conteneur boucle en restart : "could not open log file ... Permission denied" +# Cause : changement d'UID de l'utilisateur postgres entre l'image Alpine (musl) +# et l'image Debian (glibc) sous-jacente à postgis/postgis. +# ============================================================================ +set -euo pipefail + +echo "==> 1. Identifier l'UID/GID postgres DANS la nouvelle image" +# On lance un conteneur jetable avec la même image pour lire son UID interne, +# sans dépendre du conteneur en échec qui boucle en restart. +NEW_UID=$(docker run --rm postgis/postgis:16-3.5 id -u postgres) +NEW_GID=$(docker run --rm postgis/postgis:16-3.5 id -g postgres) +echo " UID postgres (nouvelle image) : $NEW_UID" +echo " GID postgres (nouvelle image) : $NEW_GID" + +echo "==> 2. Stopper le conteneur qui boucle en restart" +cd /root/wordpress +docker compose stop postgres + +echo "==> 3. Corriger les permissions sur les volumes concernés" +# pg-logs-std : cause de l'échec actuel +# pg-conf-std : même cause potentielle, à corriger par précaution +# postgres_data : à corriger aussi -- sinon le prochain démarrage échouera +# différemment une fois le problème de logs résolu (même cause, autre symptôme) + +# Volumes Docker "local" sont physiquement sous /var/lib/docker/volumes//_data +# On passe par un conteneur utilitaire pour ne pas dépendre du chemin hôte exact +# (peut varier si docker tourne en snap, comme mentionné dans l'architecture v4) + +docker run --rm \ + -v pg-logs-std:/var/log/postgresql \ + -v pg-conf-std:/etc/postgresql \ + -v postgres_data:/var/lib/postgresql/data \ + postgis/postgis:16-3.5 \ + chown -R "${NEW_UID}:${NEW_GID}" \ + /var/log/postgresql \ + /etc/postgresql \ + /var/lib/postgresql/data + +echo "==> 4. Vérification des permissions appliquées" +docker run --rm \ + -v pg-logs-std:/var/log/postgresql \ + -v pg-conf-std:/etc/postgresql \ + -v postgres_data:/var/lib/postgresql/data \ + postgis/postgis:16-3.5 \ + bash -c "ls -la /var/log/postgresql | head -5; echo '---'; ls -la /etc/postgresql | head -5; echo '---'; ls -la /var/lib/postgresql/data | head -5" + +echo "==> 5. Redémarrage du conteneur postgres" +docker compose up -d postgres + +echo "==> 6. Attente du démarrage" +for i in $(seq 1 30); do + if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres > /dev/null 2>&1; then + echo " PostgreSQL prêt après ${i}s" + break + fi + if [ "$i" -eq 30 ]; then + echo " ÉCHEC : PostgreSQL toujours pas prêt après 30s" + echo " Vérifier : docker logs wordpress-postgres --tail 50" + exit 1 + fi + sleep 1 +done + +echo "==> 7. Vérification des données" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT count(*) AS partitions FROM pg_inherits WHERE inhparent = 'adsb.positions'::regclass;" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT count(*) AS receivers FROM adsb.receivers;" + +echo "" +echo "Si les comptes ci-dessus correspondent à avant (185 partitions, 1 receiver)," +echo "les données sont intactes. On peut alors activer PostGIS :" +echo " docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \"CREATE EXTENSION IF NOT EXISTS postgis;\"" +echo "Puis redémarrer adsb2pg et webapp :" +echo " cd /data/adsb && docker compose start adsb2pg webapp" diff --git a/install_adsb_stack.sh b/install_adsb_stack.sh new file mode 100755 index 0000000..c0900b0 --- /dev/null +++ b/install_adsb_stack.sh @@ -0,0 +1,1049 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# install_adsb_stack.sh — Installation automatique du stack ADS-B complet +# Version 3.0 — JCA 2026 +# +# À LANCER SUR LA MACHINE NEUVE (root ou sudo), depuis le dossier extrait de +# l'archive produite par export_adsb_bundle.sh sur Linux-25. +# +# Comportement : automatique de bout en bout, MAIS demande confirmation avant +# toute étape destructive ou modifiant l'état système global (sysctl, udev, +# crontab, chown récursif, exécution du schéma SQL, activation systemd). +# Idempotent : peut être relancé sans casser un état déjà en place. +# +# v3.0 (05/08/2026) : aligné sur l'architecture v4.2 (multi-récepteurs). +# - détection matérielle via detect_dongles.sh : 1 à 3 dongles, aucun numéro +# de série écrit en dur nulle part +# - profiles Docker Compose rx1/rx2/rx3 pilotés par gen_env.sh +# - secrets sortis du docker-compose.yml vers /data/adsb/.env (chmod 600) +# - bridge-nf-call-iptables=0 posé dans /etc/sysctl.d/99-docker-bridge.conf +# (et non plus /etc/sysctl.conf, qui avait été écrasé sur Linux-25) +# - règle udev déléguant à systemctl restart adsb-stack.service +# - watchdog_readsb.sh v4 : seuils prudents, budget de redémarrages, sonde SBS +# - paquet rtl-sdr (rtl_test) installé : dépendance de detect_dongles.sh +# - index GiST posés d'avance sur les partitions futures +# - vérification post-install de la carte historique (fuseau horaire) +# +# v2.0 (05/07/2026) : reconstruit avec tous les correctifs découverts sur +# Linux-25 le 03-05/07/2026 — voir docs/CHANGELOG_BUNDLE.md pour le détail. +# Différences clés vs v1.0 : +# - schéma SQL généré SANS partitions datées : amorce create_week_partition() +# / create_year_partition() sur la date réelle d'installation +# - watchdog_readsb.sh v2 (seuil adaptatif jour/nuit) installé par défaut +# - cron d'automatisation des partitions (dimanche 20h, +14 jours) installé +# - vérification post-install via check_adsb.sh v3.4 en fin de script +# ═══════════════════════════════════════════════════════════════════════════════ +set -uo pipefail # pas de -e : on gère nous-mêmes les échecs étape par étape + +BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ADSB_DIR="/data/adsb" +LOG_FILE="/tmp/install_adsb_$(date +%Y%m%d_%H%M%S).log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +ok() { echo -e " ${GREEN}✓${NC} $1" | tee -a "$LOG_FILE"; } +fail() { echo -e " ${RED}✗${NC} $1" | tee -a "$LOG_FILE"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1" | tee -a "$LOG_FILE"; } +info() { echo -e " ${CYAN}→${NC} $1" | tee -a "$LOG_FILE"; } +step() { echo -e "\n${BOLD}▸ $1${NC}" | tee -a "$LOG_FILE"; } + +STEPS_OK=0 +STEPS_FAILED=0 +STEPS_SKIPPED=0 + +confirm() { + local msg="$1" + echo -e "\n ${YELLOW}⚠ CONFIRMATION REQUISE${NC}" + echo -e " $msg" + read -rp " Continuer ? [o/N] " reply + case "$reply" in + o|O|oui|OUI) return 0 ;; + *) return 1 ;; + esac +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + fail "Ce script doit être lancé en root (sudo ./install_adsb_stack.sh)" + exit 1 + fi +} + +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Installation stack ADS-B — machine : $(hostname)" +echo -e "${BOLD}║ Bundle source : $BUNDLE_DIR" +echo -e "${BOLD}║ Log complet : $LOG_FILE" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + +require_root + +# ═══════════════════════════════════════════════════════════════════════════════ +# 0. PRÉ-VOL — vérifications de base +# ═══════════════════════════════════════════════════════════════════════════════ +step "0. Pré-vol — vérifications de base" + +MISSING_TOOLS="" +for tool in docker curl python3 lsusb udevadm openssl; do + if ! command -v "$tool" &>/dev/null; then + MISSING_TOOLS="$MISSING_TOOLS $tool" + fi +done +if [ -n "$MISSING_TOOLS" ]; then + fail "Outils manquants :$MISSING_TOOLS — installe-les avant de continuer (apt install docker.io curl python3 usbutils udev openssl)" + exit 1 +else + ok "Tous les outils requis sont présents (docker, curl, python3, lsusb, udevadm, openssl)" +fi + +if ! docker compose version &>/dev/null && ! command -v docker-compose &>/dev/null; then + fail "docker compose (plugin ou binaire) introuvable" + exit 1 +else + ok "docker compose disponible" +fi + +for f in docker-compose.yml sql/schema_adsb_base.sql adsb2pg webapp; do + if [ ! -e "$BUNDLE_DIR/$f" ]; then + fail "Élément manquant dans le bundle : $f" + STEPS_FAILED=$((STEPS_FAILED+1)) + else + ok "Bundle : $f présent" + fi +done +if [ "$STEPS_FAILED" -gt 0 ]; then + fail "Bundle incomplet — abandon. Régénère-le avec export_adsb_bundle.sh" + exit 1 +fi + +# ── Détection du dongle RTL-SDR ──────────────────────────────────────────────── +RTLSDR_USB_LINE=$(lsusb | grep -i "0bda" || true) +if [ -z "$RTLSDR_USB_LINE" ]; then + fail "Aucun dongle RTL-SDR détecté (idVendor 0bda) — branche-le avant de continuer" + exit 1 +fi +ok "Dongle RTL-SDR détecté : $RTLSDR_USB_LINE" + +# ── Dépendance rtl-sdr (rtl_test) ───────────────────────────────────────────── +# detect_dongles.sh énumère les dongles via rtl_test. Sans ce paquet, il +# retombe sur un repli lsusb qui ne donne PAS les numéros de série : les slots +# seraient alors affectés à des serials « unknown1/2/3 » et readsb ne pourrait +# pas sélectionner son dongle. +if ! command -v rtl_test &>/dev/null; then + if confirm "Installer le paquet rtl-sdr (fournit rtl_test, requis pour la détection des dongles) ?"; then + apt-get update -qq && apt-get install -y rtl-sdr 2>&1 | tee -a "$LOG_FILE" + command -v rtl_test &>/dev/null \ + && ok "rtl-sdr installé ($(command -v rtl_test))" \ + || { fail "rtl_test toujours absent après installation"; exit 1; } + else + fail "rtl-sdr est requis pour la détection multi-dongles — abandon" + exit 1 + fi +else + ok "rtl_test présent : $(command -v rtl_test)" +fi + +# ── Détection des dongles présents (1 à 3) ──────────────────────────────────── +# v3.0 : plus aucun numéro de série écrit en dur. detect_dongles.sh est la +# source de vérité unique, partagée par gen_env.sh, check_adsb.sh et le +# watchdog. Les dongles sont affectés aux slots rx1..rx3 dans leur ordre +# d'énumération USB. +DETECT_SRC="$BUNDLE_DIR/scripts/detect_dongles.sh" +if [ ! -f "$DETECT_SRC" ]; then + fail "scripts/detect_dongles.sh absent du bundle — indispensable en v3.0" + exit 1 +fi +# shellcheck source=/dev/null +source "$DETECT_SRC" + +mapfile -t RTLSDR_SERIALS < <(detect_serials) +RX_COUNT=${#RTLSDR_SERIALS[@]} + +if [ "$RX_COUNT" -eq 0 ]; then + warn "Aucun numéro de série lisible via rtl_test" + warn "Certains dongles ne l'exposent pas, ou un processus détient déjà le device" + read -rp " Entre le numéro de série manuellement (ex: 00000011), ou vide pour abandonner : " MANUAL_SN + [ -z "$MANUAL_SN" ] && { fail "Au moins un dongle est requis — abandon"; exit 1; } + RTLSDR_SERIALS=("$MANUAL_SN") + RX_COUNT=1 +fi + +if [ "$RX_COUNT" -gt 3 ]; then + warn "$RX_COUNT dongles détectés — seuls les 3 premiers seront configurés" + RX_COUNT=3 +fi + +ok "$RX_COUNT dongle(s) RTL-SDR détecté(s) :" +for _i in $(seq 0 $((RX_COUNT - 1))); do + info " slot rx$((_i + 1)) → SN ${RTLSDR_SERIALS[$_i]}" +done + +# Conservé pour compatibilité avec les étapes suivantes (table receivers, etc.) +RTLSDR_SERIAL="${RTLSDR_SERIALS[0]}" + +# ═══════════════════════════════════════════════════════════════════════════════ +# 0b. VOLUMES LVM DÉDIÉS À DOCKER (optionnel, recommandé) +# ═══════════════════════════════════════════════════════════════════════════════ +# Sur Linux-25, Docker vit sur des LV dédiés (lvdocker/lvdockvol/lvdockoverlay) +# plutôt que sur la partition racine — évite qu'une base qui grossit ou des +# images qui s'accumulent ne saturent /. On reproduit cette convention ici +# si aucun stockage Docker dédié n'est déjà en place. +step "0b. Volumes LVM pour Docker" + +if ! command -v vgs &>/dev/null || ! command -v lvcreate &>/dev/null; then + warn "Outils LVM (vgs/lvcreate) absents — étape ignorée, Docker utilisera le stockage par défaut" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) +else + info "Groupes de volumes (VG) détectés :" + vgs --noheadings -o vg_name,vg_size,vg_free --units g 2>/dev/null | tee -a "$LOG_FILE" + + # Où Docker stocke-t-il déjà ses données (si installé/démarré) ? + DOCKER_ROOT="" + if command -v docker &>/dev/null && docker info &>/dev/null; then + DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null) + fi + DOCKER_ROOT="${DOCKER_ROOT:-/var/lib/docker}" + + # Ce chemin est-il déjà un point de montage dédié (donc probablement déjà + # sur un LV/disque séparé) ou fait-il juste partie de la racine ("/") ? + DOCKER_ROOT_SRC=$(findmnt -no SOURCE --target "$DOCKER_ROOT" 2>/dev/null) + ROOT_SRC=$(findmnt -no SOURCE --target "/" 2>/dev/null) + + if [ -n "$DOCKER_ROOT_SRC" ] && [ "$DOCKER_ROOT_SRC" != "$ROOT_SRC" ]; then + ok "$DOCKER_ROOT est déjà sur un point de montage dédié ($DOCKER_ROOT_SRC) — rien à faire" + else + warn "$DOCKER_ROOT est sur la partition racine ($ROOT_SRC) — pas de LV dédié à Docker" + + if confirm "Créer des volumes LVM dédiés à Docker (comme sur Linux-25 : lvdocker + lvdockvol + lvdockoverlay) et y déplacer le stockage Docker ?"; then + + mapfile -t VG_NAMES < <(vgs --noheadings -o vg_name 2>/dev/null | awk '{print $1}') + if [ "${#VG_NAMES[@]}" -eq 0 ]; then + fail "Aucun VG détecté — impossible de créer les LV. Étape ignorée." + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + elif [ "${#VG_NAMES[@]}" -eq 1 ]; then + SELECTED_VG="${VG_NAMES[0]}" + FREE=$(vgs --noheadings -o vg_free --units g "$SELECTED_VG" 2>/dev/null | tr -d ' ') + ok "Un seul VG détecté : $SELECTED_VG (libre : ${FREE}) — sélectionné automatiquement" + else + echo " VG disponibles :" + for i in "${!VG_NAMES[@]}"; do + FREE=$(vgs --noheadings -o vg_free --units g "${VG_NAMES[$i]}" 2>/dev/null | tr -d ' ') + echo " [$i] ${VG_NAMES[$i]} (libre : ${FREE})" + done + SELECTED_VG="" + while [ -z "$SELECTED_VG" ]; do + read -rp " Choisis le numéro du VG à utiliser : " VG_IDX + if [[ "$VG_IDX" =~ ^[0-9]+$ ]] && [ -n "${VG_NAMES[$VG_IDX]:-}" ]; then + SELECTED_VG="${VG_NAMES[$VG_IDX]}" + else + warn "Choix invalide ($VG_IDX) — réessaie avec un numéro entre 0 et $((${#VG_NAMES[@]}-1))" + fi + done + fi + + if [ -z "${SELECTED_VG:-}" ]; then + fail "Aucun VG sélectionné — étape ignorée" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + else + VG_FREE_G=$(vgs --noheadings -o vg_free --units g "$SELECTED_VG" 2>/dev/null | tr -d ' g') + VG_FREE_G=${VG_FREE_G%.*} + ok "VG sélectionné : $SELECTED_VG (${VG_FREE_G}G libres)" + + read -rp " Taille lvdocker (données Docker /var/lib/docker) [20G] : " SZ_DOCKER + read -rp " Taille lvdockvol (volumes nommés) [20G] : " SZ_DOCKVOL + read -rp " Taille lvdockoverlay (overlay2, images/layers) [40G] : " SZ_OVERLAY + SZ_DOCKER="${SZ_DOCKER:-20G}" + SZ_DOCKVOL="${SZ_DOCKVOL:-20G}" + SZ_OVERLAY="${SZ_OVERLAY:-40G}" + + # Vérification grossière de l'espace disponible (retire le 'G' final pour comparer) + NEED_G=$(( ${SZ_DOCKER%G} + ${SZ_DOCKVOL%G} + ${SZ_OVERLAY%G} )) + if [ "${VG_FREE_G:-0}" -lt "$NEED_G" ] 2>/dev/null; then + fail "Espace insuffisant dans $SELECTED_VG (${VG_FREE_G}G libres, ${NEED_G}G demandés) — étape ignorée" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + else + if confirm "Va créer : lvdocker (${SZ_DOCKER}), lvdockvol (${SZ_DOCKVOL}), lvdockoverlay (${SZ_OVERLAY}) dans $SELECTED_VG, les formater en ext4 (machine neuve — aucune donnée Docker existante à préserver), et arrêter momentanément Docker le temps du montage. Continuer ?"; then + + systemctl stop docker 2>/dev/null + systemctl stop docker.socket 2>/dev/null + ok "Service Docker arrêté (montage des volumes en cours)" + + declare -A LV_MOUNT=( + ["lvdocker"]="/var/lib/docker" + ["lvdockvol"]="/var/lib/docker/volumes" + ["lvdockoverlay"]="/var/lib/docker/overlay2" + ) + declare -A LV_SIZE=( + ["lvdocker"]="$SZ_DOCKER" + ["lvdockvol"]="$SZ_DOCKVOL" + ["lvdockoverlay"]="$SZ_OVERLAY" + ) + + mkdir -p /var/lib/docker/volumes /var/lib/docker/overlay2 + + LV_FAIL=0 + for lv in lvdocker lvdockvol lvdockoverlay; do + if lvs "/dev/$SELECTED_VG/$lv" &>/dev/null; then + ok "$lv existe déjà dans $SELECTED_VG — non recréé" + continue + fi + if lvcreate -L "${LV_SIZE[$lv]}" -n "$lv" "$SELECTED_VG" 2>&1 | tee -a "$LOG_FILE"; then + mkfs.ext4 -q "/dev/$SELECTED_VG/$lv" 2>&1 | tee -a "$LOG_FILE" + ok "$lv créé et formaté (ext4, ${LV_SIZE[$lv]})" + else + fail "Échec de création de $lv" + LV_FAIL=1 + fi + done + + if [ "$LV_FAIL" -eq 0 ]; then + # Sauvegarde fstab avant modification + cp /etc/fstab "/etc/fstab.bak.$(date +%Y%m%d_%H%M%S)" + ok "Sauvegarde de /etc/fstab effectuée" + + for lv in lvdocker lvdockvol lvdockoverlay; do + UUID=$(blkid -s UUID -o value "/dev/$SELECTED_VG/$lv") + MP="${LV_MOUNT[$lv]}" + mkdir -p "$MP" + if ! grep -q "$UUID" /etc/fstab; then + echo "UUID=$UUID $MP ext4 defaults 0 2" >> /etc/fstab + ok "Entrée fstab ajoutée pour $lv → $MP" + fi + mount "$MP" 2>&1 | tee -a "$LOG_FILE" + done + ok "Volumes montés — Docker utilisera désormais $SELECTED_VG pour son stockage" + else + warn "Un ou plusieurs LV n'ont pas pu être créés — Docker redémarrera sur le stockage par défaut" + fi + + systemctl start docker 2>/dev/null + systemctl start docker.socket 2>/dev/null + sleep 2 + if docker info &>/dev/null; then + ok "Docker redémarré avec succès" + else + fail "Docker ne redémarre pas correctement — vérifier 'journalctl -u docker' avant de continuer" + exit 1 + fi + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Création des LV annulée — Docker reste sur le stockage par défaut" + fi + fi + fi + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "LV dédiés non créés — Docker utilisera le stockage par défaut (partition racine)" + fi + fi +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. COPIE DES FICHIERS VERS /data/adsb +# ═══════════════════════════════════════════════════════════════════════════════ +step "1. Copie des fichiers vers $ADSB_DIR" + +if [ -d "$ADSB_DIR" ] && [ -n "$(ls -A "$ADSB_DIR" 2>/dev/null)" ]; then + if ! confirm "$ADSB_DIR existe déjà et n'est pas vide. Les fichiers seront FUSIONNÉS (pas d'écrasement des données existantes, mais les scripts/configs seront mis à jour)."; then + fail "Installation annulée par l'utilisateur" + exit 1 + fi +fi + +mkdir -p "$ADSB_DIR"/{adsb2pg,webapp,scripts,sql,logs} +cp -r "$BUNDLE_DIR/adsb2pg/." "$ADSB_DIR/adsb2pg/" && ok "adsb2pg copié" +cp -r "$BUNDLE_DIR/webapp/." "$ADSB_DIR/webapp/" && ok "webapp copié" +cp "$BUNDLE_DIR/sql/schema_adsb_base.sql" "$ADSB_DIR/sql/" && ok "schema_adsb_base.sql copié" +cp "$BUNDLE_DIR/scripts/watchdog_readsb.sh" "$ADSB_DIR/scripts/" 2>/dev/null && ok "watchdog_readsb.sh copié" +cp "$BUNDLE_DIR/check_adsb.sh" "$ADSB_DIR/" 2>/dev/null || cp "$BUNDLE_DIR/scripts/check_adsb.sh" "$ADSB_DIR/" 2>/dev/null +chmod +x "$ADSB_DIR/scripts/"*.sh "$ADSB_DIR/check_adsb.sh" 2>/dev/null +ok "Scripts rendus exécutables" + +# ── Règle udev ───────────────────────────────────────────────────────────────── +# v3.0 : la règle ne redémarre plus un conteneur nommé en dur. Elle délègue à +# adsb-stack.service, dont l'ExecStartPre appelle gen_env.sh : brancher un 2e +# dongle démarre donc le profile rx2 tout seul, et en retirer un fait +# disparaître ses conteneurs (--remove-orphans). +UDEV_SRC="$BUNDLE_DIR/scripts/99-adsb-docker.rules" +if [ -f "$UDEV_SRC" ]; then + if confirm "Installer la règle udev 99-adsb-docker.rules (recalcul auto des profiles au branchement/retrait d'un dongle) ?"; then + cp "$UDEV_SRC" /etc/udev/rules.d/ + udevadm control --reload-rules + udevadm trigger + ok "Règle udev copiée et rechargée" + + # Vérification réelle : la règle doit matcher le dongle de CETTE machine. + # On ne teste plus la présence de /dev/adsb_dongle (le symlink n'est plus + # utilisé par le compose depuis la v4.2, qui monte /dev/bus/usb en + # entier) mais la correspondance idVendor/idProduct. + USB_ID=$(lsusb | grep -i "0bda" | head -1) + RULE_MATCH=$(grep -c 'idVendor}=="0bda"' /etc/udev/rules.d/99-adsb-docker.rules 2>/dev/null || echo 0) + if [ "$RULE_MATCH" -gt 0 ] && [ -n "$USB_ID" ]; then + ok "Règle udev cohérente avec le dongle présent : $USB_ID" + else + warn "La règle ne semble pas matcher ce dongle — vérifier idVendor/idProduct :" + lsusb | grep -i "0bda" | tee -a "$LOG_FILE" + grep -i "idVendor\|idProduct" /etc/udev/rules.d/99-adsb-docker.rules 2>/dev/null | tee -a "$LOG_FILE" + warn "Le stack fonctionnera (readsb accède au bus USB en --privileged), mais sans recalcul auto des profiles" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Règle udev non installée (à faire manuellement si besoin)" + fi +else + warn "Pas de règle udev dans le bundle — à créer manuellement si besoin" +fi + +# ── Paramètres noyau : trafic inter-conteneurs Docker ───────────────────────── +# bridge-nf-call-iptables=1 fait remonter le trafic bridgé dans iptables, où il +# est jeté SILENCIEUSEMENT (timeout, pas refus de connexion). Sur Linux-25 cela +# a coupé le flux Beast readsb→tar1090 pendant 24 h. Le réglage est posé dans +# /etc/sysctl.d/ et non dans /etc/sysctl.conf, qui avait été écrasé lors d'un +# upgrade et avait fait réapparaître la panne. +if confirm "Poser bridge-nf-call-iptables=0 dans /etc/sysctl.d/99-docker-bridge.conf (requis pour la communication inter-conteneurs) ?"; then + modprobe br_netfilter 2>/dev/null || true + cat > /etc/sysctl.d/99-docker-bridge.conf <<'SYSCTL_EOF' +# Le trafic inter-conteneurs sur bridge Docker ne doit pas traverser iptables : +# les trames Beast readsb→tar1090 seraient jetées silencieusement (timeout). +net.bridge.bridge-nf-call-iptables = 0 +net.bridge.bridge-nf-call-ip6tables = 0 +SYSCTL_EOF + sysctl --system >/dev/null 2>&1 || true + BNF=$(sysctl -n net.bridge.bridge-nf-call-iptables 2>/dev/null || echo "?") + if [ "$BNF" = "0" ]; then + ok "bridge-nf-call-iptables=0 actif et permanent" + else + warn "bridge-nf-call-iptables=$BNF — le module br_netfilter se charge peut-être après sysctl.d" + warn "À revérifier après le prochain redémarrage : sysctl net.bridge.bridge-nf-call-iptables" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi +else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Paramètre noyau non posé — risque de blocage silencieux du flux Beast" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. GÉNÉRATION DU MOT DE PASSE ET CONFIGURATION docker-compose.yml +# ═══════════════════════════════════════════════════════════════════════════════ +step "2. Configuration docker-compose.yml" + +NEW_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 25) +ok "Nouveau mot de passe PostgreSQL généré (25 caractères, jamais réutilisé d'une machine à l'autre)" + +# v3.0 : le mot de passe ne figure plus dans docker-compose.yml. Il vit dans +# /data/adsb/.env (chmod 600, non versionné), lu par Compose via --env-file. +umask 077 +cat > "$ADSB_DIR/.env" < "$ADSB_DIR/.env.gains" <<'GAINS_EOF' +# Gain par slot : valeur en dB (ex. 49.6) ou « autogain ». +# Un gain trop bas rend le récepteur muet SANS erreur : readsb accepte toute +# valeur de la table du tuner. Constaté sur Linux-25 avec 7.7 dB — 0 message +# décodé pendant 24 h. En cas de doute, laisser autogain. +RX1_GAIN=autogain +RX2_GAIN=autogain +RX3_GAIN=autogain +GAINS_EOF + ok "Gains par défaut écrits dans $ADSB_DIR/.env.gains (autogain)" +fi + +# Génération initiale de .env.receivers (serials + COMPOSE_PROFILES) +if [ -x "$ADSB_DIR/scripts/gen_env.sh" ]; then + "$ADSB_DIR/scripts/gen_env.sh" >/dev/null 2>&1 || true + PROFILES=$(sed -n 's/^COMPOSE_PROFILES=//p' "$ADSB_DIR/.env.receivers" 2>/dev/null) + ok "Environnement récepteurs généré — profiles=${PROFILES:-aucun}" +else + fail "scripts/gen_env.sh absent ou non exécutable — le stack ne pourra pas démarrer ses récepteurs" + exit 1 +fi + +# Coordonnées de la station : proposées par défaut, modifiables +read -rp " Latitude de la station [48.8236] : " STATION_LAT +read -rp " Longitude de la station [2.2770] : " STATION_LON +STATION_LAT="${STATION_LAT:-48.8236}" +STATION_LON="${STATION_LON:-2.2770}" +sed -i -e "s/READSB_LAT=[^[:space:]]*/READSB_LAT=${STATION_LAT}/" \ + -e "s/READSB_LON=[^[:space:]]*/READSB_LON=${STATION_LON}/" \ + -e "s/LAT=[^[:space:]]*/LAT=${STATION_LAT}/" \ + -e "s/LONG=[^[:space:]]*/LONG=${STATION_LON}/" \ + -e "s/STATION_LAT=[^[:space:]]*/STATION_LAT=${STATION_LAT}/" \ + -e "s/STATION_LON=[^[:space:]]*/STATION_LON=${STATION_LON}/" \ + -e "s/TAR1090_DEFAULTCENTERLAT=[^[:space:]]*/TAR1090_DEFAULTCENTERLAT=${STATION_LAT}/" \ + -e "s/TAR1090_DEFAULTCENTERLON=[^[:space:]]*/TAR1090_DEFAULTCENTERLON=${STATION_LON}/" \ + "$ADSB_DIR/docker-compose.yml" +ok "Coordonnées station : $STATION_LAT, $STATION_LON" + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. LANCEMENT DES CONTENEURS +# ═══════════════════════════════════════════════════════════════════════════════ +step "3. Lancement des conteneurs Docker" + +# v3.0 : les DEUX --env-file sont obligatoires. Dès qu'on en passe un, Compose +# cesse de charger .env automatiquement ; et sans .env.receivers, COMPOSE_PROFILES +# n'est pas défini — aucun récepteur n'est alors éligible et seule la webapp +# démarre, sans le moindre message d'erreur. +if confirm "Lancer 'docker compose up -d' dans $ADSB_DIR (démarre les récepteurs détectés, webapp, postgres) ?"; then + (cd "$ADSB_DIR" && docker compose \ + --env-file "$ADSB_DIR/.env" \ + --env-file "$ADSB_DIR/.env.receivers" \ + up -d --build --remove-orphans) 2>&1 | tee -a "$LOG_FILE" + if [ "${PIPESTATUS[0]}" -eq 0 ]; then + ok "Conteneurs démarrés" + else + fail "Échec du démarrage des conteneurs — voir $LOG_FILE" + exit 1 + fi +else + fail "Installation interrompue — les conteneurs doivent tourner pour la suite" + exit 1 +fi + +info "Attente du démarrage de PostgreSQL (jusqu'à 60s)..." +PG_READY=false +for i in $(seq 1 30); do + if docker exec wordpress-postgres pg_isready -U pgz_admin &>/dev/null; then + PG_READY=true + break + fi + sleep 2 +done +if $PG_READY; then + ok "PostgreSQL prêt après ~$((i*2))s" +else + fail "PostgreSQL ne répond pas après 60s — vérifier 'docker logs wordpress-postgres'" + exit 1 +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3b. TUNING POSTGRESQL SELON LA RAM DE LA MACHINE +# ═══════════════════════════════════════════════════════════════════════════════ +# Les valeurs codées en dur sur Linux-25 (shared_buffers=3200MB, etc.) ont été +# calibrées pour SES 16 Go de RAM — les réutiliser telles quelles sur une +# machine avec moins (ou plus) de mémoire serait au mieux sous-optimal, au pire +# risquerait un échec de démarrage PostgreSQL (shared_buffers > RAM dispo). +# On calcule donc les valeurs à partir de la RAM réellement détectée. +step "3b. Tuning PostgreSQL selon la RAM de la machine" + +TOTAL_RAM_MB=$(free -m | awk '/^Mem:/{print $2}') +ok "RAM totale détectée : ${TOTAL_RAM_MB} MB" + +if [ "${TOTAL_RAM_MB:-0}" -lt 2048 ]; then + warn "RAM très limitée (<2GB) — tuning conservateur appliqué, les performances seront limitées" +fi + +# Formules calibrées pour reproduire les ratios observés sur Linux-25 (16384 MB +# → shared_buffers 3200MB ≈ 20%, effective_cache_size 9216MB ≈ 56%, +# work_mem 32MB ≈ 0.2%, max_wal_size 4096MB ≈ 25%) tout en gardant des planchers +# raisonnables pour les petites machines. +SHARED_BUFFERS_MB=$(( TOTAL_RAM_MB * 20 / 100 )) +EFFECTIVE_CACHE_MB=$(( TOTAL_RAM_MB * 56 / 100 )) +WORK_MEM_MB=$(( TOTAL_RAM_MB * 2 / 1000 )) +MAINTENANCE_WORK_MEM_MB=$(( SHARED_BUFFERS_MB / 6 )) +MAX_WAL_SIZE_MB=$(( TOTAL_RAM_MB * 25 / 100 )) +MIN_WAL_SIZE_MB=$(( MAX_WAL_SIZE_MB / 4 )) + +# Planchers pour éviter des valeurs absurdes sur une machine à faible RAM +[ "$SHARED_BUFFERS_MB" -lt 128 ] && SHARED_BUFFERS_MB=128 +[ "$WORK_MEM_MB" -lt 4 ] && WORK_MEM_MB=4 +[ "$MAINTENANCE_WORK_MEM_MB" -lt 64 ] && MAINTENANCE_WORK_MEM_MB=64 +[ "$MAX_WAL_SIZE_MB" -lt 512 ] && MAX_WAL_SIZE_MB=512 +[ "$MIN_WAL_SIZE_MB" -lt 128 ] && MIN_WAL_SIZE_MB=128 + +# Détection SSD vs HDD sur le disque hébergeant /var/lib/docker (best-effort) — +# influence random_page_cost et effective_io_concurrency. +DOCKER_DISK_DEV=$(findmnt -no SOURCE --target /var/lib/docker 2>/dev/null | sed -E 's/[0-9]+$//; s/.*\///') +ROTATIONAL=1 +if [ -n "$DOCKER_DISK_DEV" ] && [ -f "/sys/block/$DOCKER_DISK_DEV/queue/rotational" ]; then + ROTATIONAL=$(cat "/sys/block/$DOCKER_DISK_DEV/queue/rotational" 2>/dev/null) +fi +if [ "$ROTATIONAL" = "0" ]; then + RANDOM_PAGE_COST="1.1" + EFFECTIVE_IO_CONCURRENCY="150" + ok "Disque détecté comme SSD ($DOCKER_DISK_DEV) — random_page_cost=1.1, effective_io_concurrency=150" +else + RANDOM_PAGE_COST="4" + EFFECTIVE_IO_CONCURRENCY="2" + warn "Disque détecté comme rotatif/HDD ou indéterminé ($DOCKER_DISK_DEV) — random_page_cost=4, effective_io_concurrency=2 (confirme manuellement si c'est bien un SSD)" +fi + +echo "" +echo " Valeurs calculées pour ${TOTAL_RAM_MB} MB de RAM :" +echo " shared_buffers = ${SHARED_BUFFERS_MB}MB" +echo " effective_cache_size = ${EFFECTIVE_CACHE_MB}MB" +echo " work_mem = ${WORK_MEM_MB}MB" +echo " maintenance_work_mem = ${MAINTENANCE_WORK_MEM_MB}MB" +echo " max_wal_size = ${MAX_WAL_SIZE_MB}MB" +echo " min_wal_size = ${MIN_WAL_SIZE_MB}MB" +echo " random_page_cost = ${RANDOM_PAGE_COST}" +echo " effective_io_concurrency = ${EFFECTIVE_IO_CONCURRENCY}" + +if confirm "Appliquer ce tuning PostgreSQL via ALTER SYSTEM SET (redémarre le conteneur postgres pour prendre effet) ?"; then + docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c " + ALTER SYSTEM SET shared_buffers = '${SHARED_BUFFERS_MB}MB'; + ALTER SYSTEM SET effective_cache_size = '${EFFECTIVE_CACHE_MB}MB'; + ALTER SYSTEM SET work_mem = '${WORK_MEM_MB}MB'; + ALTER SYSTEM SET maintenance_work_mem = '${MAINTENANCE_WORK_MEM_MB}MB'; + ALTER SYSTEM SET max_wal_size = '${MAX_WAL_SIZE_MB}MB'; + ALTER SYSTEM SET min_wal_size = '${MIN_WAL_SIZE_MB}MB'; + ALTER SYSTEM SET random_page_cost = ${RANDOM_PAGE_COST}; + ALTER SYSTEM SET effective_io_concurrency = ${EFFECTIVE_IO_CONCURRENCY}; + ALTER SYSTEM SET max_locks_per_transaction = 2048; + " 2>&1 | tee -a "$LOG_FILE" + + docker restart wordpress-postgres 2>&1 | tee -a "$LOG_FILE" + info "Attente du redémarrage de PostgreSQL..." + sleep 3 + PG_READY2=false + for i in $(seq 1 20); do + if docker exec wordpress-postgres pg_isready -U pgz_admin &>/dev/null; then + PG_READY2=true + break + fi + sleep 2 + done + + if $PG_READY2; then + ok "PostgreSQL redémarré avec le nouveau tuning" + # Vérification que shared_buffers a bien été pris en compte (pas resté + # sur une valeur d'auto.conf pré-existante — piège rencontré sur Linux-25) + ACTUAL_SB=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc "SHOW shared_buffers;" 2>/dev/null | tr -d ' ') + info "shared_buffers effectif (runtime) : $ACTUAL_SB (attendu ~${SHARED_BUFFERS_MB}MB)" + else + fail "PostgreSQL ne redémarre pas après le tuning — vérifier 'docker logs wordpress-postgres' (valeur shared_buffers trop élevée pour la RAM/shmmax du noyau ?)" + exit 1 + fi +else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Tuning PostgreSQL non appliqué — valeurs par défaut de l'image conservées (sous-optimal mais fonctionnel)" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. SCHÉMA POSTGRESQL +# ═══════════════════════════════════════════════════════════════════════════════ +step "4. Application du schéma PostgreSQL" + +SCHEMA_EXISTS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc \ + "SELECT 1 FROM information_schema.schemata WHERE schema_name='adsb';" 2>/dev/null) + +if [ "$SCHEMA_EXISTS" = "1" ]; then + warn "Le schéma 'adsb' existe déjà dans cette base — schéma NON réappliqué (évite d'écraser des données)" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) +else + if confirm "Appliquer schema_adsb_base.sql (crée le schéma adsb : tables, fonctions, partitions courantes) ?"; then + docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ + --set ON_ERROR_STOP=1 < "$ADSB_DIR/sql/schema_adsb_base.sql" 2>&1 | tee -a "$LOG_FILE" + if [ "${PIPESTATUS[0]}" -eq 0 ]; then + ok "Schéma adsb créé, partitions courantes amorcées" + else + fail "Échec de l'application du schéma — voir $LOG_FILE (ON_ERROR_STOP a stoppé au premier problème, pas d'état partiel dangereux)" + exit 1 + fi + else + fail "Installation interrompue — le schéma est requis pour la suite" + exit 1 + fi +fi + +# ── Correctif ISOYEAR sur create_week_partition ─────────────────────────────── +# v3.0 : la fonction d'origine mélangeait EXTRACT(YEAR) (année civile) et +# EXTRACT(WEEK) (semaine ISO). Aux charnières d'année les deux divergent : une +# partition pouvait être nommée d'après une semaine déjà prise, le test +# d'existence renvoyait « existe déjà », la partition n'était PAS créée et les +# insertions de cette semaine échouaient silencieusement. +if [ -f "$ADSB_DIR/sql/create_week_partition_fix.sql" ]; then + if confirm "Appliquer le correctif ISOYEAR sur adsb.create_week_partition ?"; then + docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ + --set ON_ERROR_STOP=1 < "$ADSB_DIR/sql/create_week_partition_fix.sql" 2>&1 | tee -a "$LOG_FILE" + [ "${PIPESTATUS[0]}" -eq 0 ] \ + && ok "create_week_partition corrigée (ISOYEAR + garde sur les bornes)" \ + || { warn "Échec du correctif ISOYEAR — à appliquer manuellement"; STEPS_SKIPPED=$((STEPS_SKIPPED+1)); } + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Correctif ISOYEAR non appliqué — risque de partition manquante aux changements d'année" + fi +else + warn "sql/create_week_partition_fix.sql absent du bundle" +fi + +# ── Zones d'exclusion pour l'analyse des départs ────────────────────────────── +# Alimente les onglets « Départs hors aérodromes » et « Zones de départ » : +# tout départ détecté dans le rayon d'une zone active est écarté des résultats. +# Le référentiel couvre l'Île-de-France ; sur une autre implantation, il faudra +# l'adapter depuis l'interface (bouton « Gérer les exclusions »). +for SQLZ in 04_airports.sql 05_airports_zones.sql; do + if [ -f "$ADSB_DIR/sql/$SQLZ" ]; then + docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ + --set ON_ERROR_STOP=1 < "$ADSB_DIR/sql/$SQLZ" >> "$LOG_FILE" 2>&1 + if [ $? -eq 0 ]; then + ok "$SQLZ appliqué" + else + warn "Échec de $SQLZ — voir $LOG_FILE" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi + else + warn "sql/$SQLZ absent du bundle — l'analyse des départs sera indisponible" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi +done + +AERO_N=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc \ + "SELECT count(*) FROM adsb.airports WHERE actif;" 2>/dev/null) +if [ "${AERO_N:-0}" -gt 0 ]; then + ok "$AERO_N zone(s) d'exclusion active(s)" + warn "Référentiel calibré pour l'Île-de-France : vérifier les aérodromes" + warn " proches de CETTE station via l'onglet « Zones de départ »" +else + warn "Aucune zone d'exclusion active — l'analyse des départs remontera tout le trafic" +fi + +# ── Partitions et index GiST d'avance ───────────────────────────────────────── +# Les requêtes cartographiques filtrent sur geom : sans index GiST sur la +# partition en cours d'écriture, la carte historique part en timeout et revient +# vide. Le symptôme apparaît au rollover hebdomadaire, pas à l'installation — +# d'où la création anticipée sur 8 semaines. +if confirm "Créer les partitions des 8 prochaines semaines avec leurs index GiST ?"; then + for W in $(seq 0 7); do + docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc \ + "SELECT adsb.create_week_partition((CURRENT_DATE + INTERVAL '$((W * 7)) days')::date);" \ + >> "$LOG_FILE" 2>&1 || true + done + # Filet de sécurité : certaines partitions peuvent préexister sans index. + docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c " + DO \$\$ + DECLARE r record; + BEGIN + FOR r IN + SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'adsb.positions'::regclass + AND NOT EXISTS (SELECT 1 FROM pg_index x JOIN pg_class ic ON ic.oid = x.indexrelid + WHERE x.indrelid = c.oid AND ic.relname LIKE '%geom_gix') + LOOP + EXECUTE format('CREATE INDEX %I ON adsb.%I USING GIST (geom)', + r.relname || '_geom_gix', r.relname); + END LOOP; + END \$\$;" 2>&1 | tee -a "$LOG_FILE" + PART_COUNT=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc \ + "SELECT count(*) FROM pg_inherits WHERE inhparent='adsb.positions'::regclass;" 2>/dev/null) + ok "Partitions amorcées (${PART_COUNT:-?} au total) et index GiST posés" +else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Partitions non amorcées — la carte historique sera vide au prochain rollover" +fi + +# ── Enregistrement des récepteurs dans adsb.receivers ───────────────────────── +# v3.0 : boucle sur tous les dongles détectés, un enregistrement par slot. +step "4b. Enregistrement des récepteurs" +for _i in $(seq 0 $((RX_COUNT - 1))); do + _SN="${RTLSDR_SERIALS[$_i]}" + _SLOT=$((_i + 1)) + RECEIVER_EXISTS=$(docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -tAc \ + "SELECT 1 FROM adsb.receivers WHERE serial='${_SN}';" 2>/dev/null) + + if [ "$RECEIVER_EXISTS" = "1" ]; then + ok "Récepteur serial=$_SN déjà enregistré" + else + docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c " + INSERT INTO adsb.receivers (serial, name, manufacturer, model, chipset, lat, lon, location_desc, active) + VALUES ('${_SN}', '$(hostname) rx${_SLOT}', 'Generic', 'RTL2832U', 'R820T', ${STATION_LAT}, ${STATION_LON}, '$(hostname) — slot rx${_SLOT}, installation automatique', true) + ON CONFLICT (serial) DO NOTHING; + " 2>&1 | tee -a "$LOG_FILE" + ok "Récepteur enregistré dans adsb.receivers (serial=$_SN, slot rx${_SLOT})" + fi +done + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5. CRONTAB — watchdog + automatisation des partitions +# ═══════════════════════════════════════════════════════════════════════════════ +step "5. Tâches planifiées (crontab)" + +CURRENT_CRON=$(crontab -l 2>/dev/null || echo "") +NEW_CRON="$CURRENT_CRON" + +if echo "$CURRENT_CRON" | grep -q "watchdog_readsb.sh"; then + ok "Entrée crontab watchdog déjà présente" +else + if confirm "Ajouter au crontab : watchdog readsb toutes les 5 min (*/5 * * * * $ADSB_DIR/scripts/watchdog_readsb.sh) ?"; then + NEW_CRON="${NEW_CRON} +*/5 * * * * $ADSB_DIR/scripts/watchdog_readsb.sh" + ok "Ligne watchdog ajoutée" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Watchdog non planifié — le dongle ne sera pas relancé automatiquement en cas de gel" + fi +fi + +# Le watchdog v4 exige detect_dongles.sh : sans lui il refuse de tourner plutôt +# que de redémarrer un slot au hasard. +if [ -f "$ADSB_DIR/scripts/watchdog_readsb.sh" ]; then + if grep -q "detect_dongles.sh" "$ADSB_DIR/scripts/watchdog_readsb.sh"; then + ok "Watchdog v4 installé (récepteurs dérivés de detect_dongles.sh)" + else + warn "Watchdog antérieur à la v4 : seuil trop agressif, aucun budget de redémarrages" + warn "Il empêcherait l'autogain de converger — mettre à jour scripts/watchdog_readsb.sh" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi +fi + +if echo "$CURRENT_CRON" | grep -q "create_week_partition"; then + ok "Entrée crontab automatisation partitions déjà présente" +else + if confirm "Ajouter au crontab : création automatique de la partition +14 jours chaque dimanche 20h ?"; then + NEW_CRON="${NEW_CRON} +0 20 * * 0 docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \"SELECT adsb.create_week_partition((CURRENT_DATE + INTERVAL '14 days')::date);\" >> $ADSB_DIR/logs/partition_cron.log 2>&1" + ok "Ligne d'automatisation des partitions ajoutée" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Automatisation des partitions non planifiée — pense à créer les partitions manuellement chaque semaine" + fi +fi + +if [ "$NEW_CRON" != "$CURRENT_CRON" ]; then + echo "$NEW_CRON" | crontab - + ok "Crontab mis à jour" +fi + +# ── Vérification RÉELLE de la crontab (pas juste "la commande n'a pas échoué") ── +# Sur une machine neuve minimaliste, le service cron peut être absent/désactivé +# — dans ce cas les lignes crontab sont enregistrées mais ne s'exécuteront +# jamais, ce qui serait un échec silencieux découvert seulement des jours plus +# tard (exactement le genre d'incident qu'on a eu sur Linux-25). +info "Vérification de la crontab installée..." +INSTALLED_CRON=$(crontab -l 2>/dev/null || echo "") +CRON_TESTS_OK=true + +if echo "$INSTALLED_CRON" | grep -q "watchdog_readsb.sh"; then + ok "Ligne watchdog confirmée dans la crontab" +else + warn "Ligne watchdog ABSENTE de la crontab (ignorée précédemment ou échec d'écriture)" + CRON_TESTS_OK=false +fi + +if echo "$INSTALLED_CRON" | grep -q "create_week_partition"; then + ok "Ligne automatisation partitions confirmée dans la crontab" +else + warn "Ligne automatisation partitions ABSENTE de la crontab" + CRON_TESTS_OK=false +fi + +CRON_SERVICE="" +for svc in cron crond; do + if systemctl list-unit-files 2>/dev/null | grep -q "^${svc}\.service"; then + CRON_SERVICE="$svc" + break + fi +done + +if [ -z "$CRON_SERVICE" ]; then + fail "Aucun service cron/crond installé sur cette machine — les tâches planifiées ne s'exécuteront JAMAIS" + if confirm "Installer le paquet cron maintenant (apt install -y cron) ?"; then + apt install -y cron 2>&1 | tee -a "$LOG_FILE" + systemctl enable --now cron 2>&1 | tee -a "$LOG_FILE" + ok "Paquet cron installé et activé" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "cron non installé — watchdog et automatisation des partitions resteront inactifs" + fi +else + if systemctl is-active --quiet "$CRON_SERVICE"; then + ok "Service $CRON_SERVICE actif" + else + warn "Service $CRON_SERVICE installé mais INACTIF" + if confirm "Activer et démarrer $CRON_SERVICE maintenant ?"; then + systemctl enable --now "$CRON_SERVICE" 2>&1 | tee -a "$LOG_FILE" + ok "$CRON_SERVICE activé et démarré" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "$CRON_SERVICE reste inactif — les tâches planifiées ne s'exécuteront pas" + fi + fi +fi + +if ! $CRON_TESTS_OK; then + info "Rappel : relance ce script pour proposer à nouveau les entrées crontab manquantes (idempotent)" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5b. PARE-FEU +# ═══════════════════════════════════════════════════════════════════════════════ +# Le stack expose 8080 (webapp), 8090 (tar1090) et 30005 (Beast TCP) sur toutes +# les interfaces (0.0.0.0) selon docker-compose.yml — un pare-feu actif sur la +# machine neuve pourrait bloquer l'accès LAN à ces services même si les +# conteneurs tournent parfaitement. +step "5c. Pare-feu" + +FIREWALL_TOOL="" +if command -v ufw &>/dev/null; then + FIREWALL_TOOL="ufw" +elif command -v firewall-cmd &>/dev/null; then + FIREWALL_TOOL="firewalld" +fi + +if [ -z "$FIREWALL_TOOL" ]; then + info "Aucun pare-feu (ufw/firewalld) détecté sur cette machine — aucune restriction à gérer" +else + case "$FIREWALL_TOOL" in + ufw) + UFW_STATUS=$(ufw status 2>/dev/null | head -1) + info "ufw détecté — $UFW_STATUS" + if echo "$UFW_STATUS" | grep -qi "inactive"; then + ok "ufw inactif — aucune règle à ajouter, tous les ports sont déjà accessibles" + else + echo " Règles ufw actuelles :" + ufw status numbered 2>/dev/null | tee -a "$LOG_FILE" + for PORT_DESC in "8080:webapp" "8090:tar1090" "30005:Beast TCP"; do + PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC##*:}" + if ufw status 2>/dev/null | grep -qE "^${PORT}(/tcp)?\s+ALLOW"; then + ok "Port $PORT ($DESC) déjà autorisé" + else + if confirm "ufw est actif et bloque probablement le port $PORT ($DESC) — l'autoriser (ufw allow $PORT/tcp) ?"; then + ufw allow "${PORT}/tcp" 2>&1 | tee -a "$LOG_FILE" + ok "Port $PORT ($DESC) autorisé" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Port $PORT ($DESC) non autorisé — inaccessible depuis le LAN tant que la règle n'est pas ajoutée" + fi + fi + done + fi + ;; + firewalld) + FW_STATE=$(firewall-cmd --state 2>/dev/null) + info "firewalld détecté — état : $FW_STATE" + if [ "$FW_STATE" != "running" ]; then + ok "firewalld non actif — aucune règle à ajouter" + else + for PORT_DESC in "8080:webapp" "8090:tar1090" "30005:Beast TCP"; do + PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC##*:}" + if firewall-cmd --list-ports 2>/dev/null | grep -q "${PORT}/tcp"; then + ok "Port $PORT ($DESC) déjà autorisé" + else + if confirm "firewalld est actif et bloque probablement le port $PORT ($DESC) — l'autoriser en permanence ?"; then + firewall-cmd --permanent --add-port="${PORT}/tcp" 2>&1 | tee -a "$LOG_FILE" + ok "Port $PORT ($DESC) autorisé (permanent)" + else + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + warn "Port $PORT ($DESC) non autorisé" + fi + fi + done + firewall-cmd --reload 2>&1 | tee -a "$LOG_FILE" + ok "firewalld rechargé" + fi + ;; + esac +fi + +# Test RÉEL de portée réseau (indépendant du pare-feu — confirme l'accès effectif) +info "Test d'accès réel aux ports exposés (depuis localhost)..." +for PORT_DESC in "8080:webapp" "8090:tar1090"; do + PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC##*:}" + if curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://127.0.0.1:${PORT}/" 2>/dev/null | grep -q "200"; then + ok "Port $PORT ($DESC) répond HTTP 200" + else + warn "Port $PORT ($DESC) ne répond pas encore — normal si les conteneurs finissent tout juste de démarrer" + fi +done + +# ═══════════════════════════════════════════════════════════════════════════════ +# 6. VÉRIFICATION FINALE +# ═══════════════════════════════════════════════════════════════════════════════ +step "6. Vérification finale" + +sleep 5 # laisser le temps à adsb2pg de faire au moins un cycle + +# ── Contrôles fonctionnels v3.0 ─────────────────────────────────────────────── +# Ces trois vérifications correspondent aux pannes réellement rencontrées sur +# Linux-25 : chacune produisait un stack « vert » mais inexploitable. + +# 1. Le flux Beast passe-t-il de readsb à tar1090 ? (bridge-nf-call-iptables) +info "Vérification du flux Beast readsb → tar1090 (30s d'attente)..." +sleep 30 +AC_MSGS=$(curl -s --max-time 10 "http://127.0.0.1:8090/data/aircraft.json" 2>/dev/null \ + | grep -oP '"messages"\s*:\s*\K[0-9]+' | head -1) +if [ "${AC_MSGS:-0}" -gt 0 ]; then + ok "tar1090 agrège ${AC_MSGS} messages — le flux Beast passe" +else + warn "tar1090 annonce 0 message — vérifier dans l'ordre :" + warn " a) sysctl net.bridge.bridge-nf-call-iptables (doit valoir 0)" + warn " b) le gain : un gain trop bas rend readsb muet sans erreur" + warn " c) docker logs adsb-tar1090 | grep -i beast (timeout = filtrage)" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) +fi + +# 2. Le récepteur décode-t-il réellement ? (sonde SBS directe, sans tar1090) +SBS_BYTES=$(timeout 8 bash -c 'exec 3<>/dev/tcp/127.0.0.1/30003 && head -c 400 <&3' 2>/dev/null | wc -c) +if [ "${SBS_BYTES:-0}" -ge 50 ]; then + ok "Port SBS 30003 : ${SBS_BYTES} octets reçus — le récepteur décode" +else + warn "Port SBS 30003 silencieux — readsb ne décode rien (gain, antenne ou connecteur)" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) +fi + +# 3. La carte historique renvoie-t-elle des données ? (fuseau horaire) +# Sans date_default_timezone_set dans api.php, PHP retombe sur UTC et la +# fenêtre interrogée est décalée : la carte reste vide sans aucune erreur HTTP. +if [ -f "$ADSB_DIR/webapp/src/api.php" ]; then + if grep -q "date_default_timezone_set" "$ADSB_DIR/webapp/src/api.php"; then + ok "api.php : fuseau horaire ancré" + else + warn "api.php : date_default_timezone_set absent — carte historique vide (décalage UTC/locale)" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi +fi + +# 5. Les endpoints d'analyse des départs répondent-ils ? +for ACT in airports departures_offfield departures_offfield_zones; do + BODY=$(curl -s --max-time 25 "http://127.0.0.1:8080/api.php?action=${ACT}&window=1440" 2>/dev/null) + if echo "$BODY" | grep -q '"error"'; then + warn "api.php?action=${ACT} : $(echo "$BODY" | head -c 120)" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + elif echo "$BODY" | head -c 1 | grep -q '{'; then + ok "api.php?action=${ACT} : réponse valide" + else + warn "api.php?action=${ACT} : réponse inattendue" + STEPS_SKIPPED=$((STEPS_SKIPPED+1)) + fi +done + +# 6. Les drapeaux sont-ils servis ? (sinon le JS retombe sur le pictogramme « ? ») +FLAG_N=$(docker exec adsb-webapp sh -c 'ls /var/www/html/assets/flags/*.png 2>/dev/null | wc -l' 2>/dev/null) +if [ "${FLAG_N:-0}" -gt 50 ]; then + ok "$FLAG_N drapeau(x) disponible(s) dans l'image" +else + warn "Seulement ${FLAG_N:-0} drapeau(x) — les pays s'afficheront avec un pictogramme générique" + warn " Relancer : /data/adsb/scripts/fetch_flags.sh puis rebuild de la webapp" +fi + +if [ -x "$ADSB_DIR/check_adsb.sh" ]; then + info "Lancement de check_adsb.sh pour valider l'installation..." + "$ADSB_DIR/check_adsb.sh" 2>&1 | tee -a "$LOG_FILE" +else + warn "check_adsb.sh absent ou non exécutable — vérification manuelle recommandée :" + info " docker ps" + info " curl -s http://127.0.0.1:8090/data/aircraft.json | head -c 300" +fi + +echo -e "\n${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ INSTALLATION TERMINÉE ║${NC}" +echo -e "${BOLD}╠══════════════════════════════════════════════════════╣${NC}" +echo -e "${BOLD}║ Récepteur : serial=$RTLSDR_SERIAL" +echo -e "${BOLD}║ Log complet : $LOG_FILE" +echo -e "${BOLD}║ Dashboard : http://$(hostname -I | awk '{print $1}'):8080" +echo -e "${BOLD}║ tar1090 : http://$(hostname -I | awk '{print $1}'):8090" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + +if [ "$STEPS_SKIPPED" -gt 0 ]; then + warn "$STEPS_SKIPPED étape(s) ignorée(s) sur demande — relance ce script pour les proposer à nouveau si besoin (idempotent)" +fi diff --git a/migrate_to_postgis.sh b/migrate_to_postgis.sh new file mode 100755 index 0000000..66bbec6 --- /dev/null +++ b/migrate_to_postgis.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# ============================================================================ +# Bascule postgres:16-alpine -> postgis/postgis:16-3.4 +# Stack WordPress + ADS-B, Linux-25 +# Site personnel : indisponibilité tolérée, on fait ça en une seule fenêtre. +# ============================================================================ +set -euo pipefail + +COMPOSE_DIR="/root/wordpress" +BACKUP_DIR="/backup/postgres_migration_$(date +%Y%m%d_%H%M%S)" + +echo "==> 1. Sauvegarde de précaution AVANT toute modification" +mkdir -p "$BACKUP_DIR" + +# Dump logique complet (indépendant du format binaire, donc restaurable +# même si jamais l'image change quelque chose de fondamental) +docker exec wordpress-postgres pg_dump -U pgz_admin -d wpz_postgres -F c \ + -f /tmp/wpz_postgres_pre_postgis.dump +docker cp wordpress-postgres:/tmp/wpz_postgres_pre_postgis.dump \ + "$BACKUP_DIR/wpz_postgres_pre_postgis.dump" + +echo " Dump sauvegardé : $BACKUP_DIR/wpz_postgres_pre_postgis.dump" +ls -lh "$BACKUP_DIR/wpz_postgres_pre_postgis.dump" + +echo "==> 2. Sauvegarde du docker-compose.yml actuel" +cp "$COMPOSE_DIR/docker-compose.yml" "$BACKUP_DIR/docker-compose.yml.bak" + +echo "==> 3. Édition de l'image dans docker-compose.yml" +sed -i 's|image: postgres:16-alpine|image: postgis/postgis:16-3.5|' \ + "$COMPOSE_DIR/docker-compose.yml" + +grep -A1 "container_name: wordpress-postgres" "$COMPOSE_DIR/docker-compose.yml" || true +grep "postgis/postgis" "$COMPOSE_DIR/docker-compose.yml" + +echo "==> 4. Pull de la nouvelle image" +cd "$COMPOSE_DIR" +docker compose pull postgres + +echo "==> 5. Arrêt propre des conteneurs dépendants (ADS-B) avant bascule" +echo " (évite des erreurs de connexion pendant le redémarrage PG)" +cd /data/adsb +docker compose stop adsb2pg webapp + +echo "==> 6. Recreate du conteneur postgres avec la nouvelle image" +cd "$COMPOSE_DIR" +docker compose up -d postgres + +echo "==> 7. Attente du démarrage (healthcheck implicite via pg_isready)" +for i in $(seq 1 30); do + if docker exec wordpress-postgres pg_isready -U pgz_admin -d wpz_postgres > /dev/null 2>&1; then + echo " PostgreSQL prêt après ${i}s" + break + fi + sleep 1 +done + +echo "==> 8. Vérification : les données existantes sont toujours là" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT count(*) AS partitions FROM pg_inherits WHERE inhparent = 'adsb.positions'::regclass;" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT count(*) AS receivers FROM adsb.receivers;" + +echo "==> 9. Activation de PostGIS" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "CREATE EXTENSION IF NOT EXISTS postgis;" +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT postgis_full_version();" + +echo "==> 10. Redémarrage des conteneurs ADS-B" +cd /data/adsb +docker compose start adsb2pg webapp +sleep 5 +docker compose ps + +echo "==> 11. Vérification finale -- adsb2pg insère toujours correctement" +sleep 10 +docker exec wordpress-postgres psql -U pgz_admin -d wpz_postgres -c \ + "SELECT max(ts) AS derniere_insertion, count(*) AS lignes_30min + FROM adsb.positions + WHERE ts >= NOW() - INTERVAL '5 minutes';" + +echo "" +echo "============================================================" +echo "Migration terminée. En cas de problème, restauration :" +echo " cp $BACKUP_DIR/docker-compose.yml.bak $COMPOSE_DIR/docker-compose.yml" +echo " cd $COMPOSE_DIR && docker compose up -d postgres" +echo " (puis restaurer le dump si nécessaire :" +echo " docker exec -i wordpress-postgres pg_restore -U pgz_admin -d wpz_postgres --clean" +echo " < $BACKUP_DIR/wpz_postgres_pre_postgis.dump)" +echo "============================================================" diff --git a/monitor_postgres_tables.sh b/monitor_postgres_tables.sh new file mode 100755 index 0000000..118922a --- /dev/null +++ b/monitor_postgres_tables.sh @@ -0,0 +1,283 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# monitor_postgres_tables.sh — Surveillance tailles tables ADS-B PostgreSQL +# Version : 2.0 — JCA 2026 +# Usage : ./monitor_postgres_tables.sh [--alert THRESHOLD_MB] [--init] +# --alert N : alerte si positions ou aircraft_history dépasse N Mo (défaut 5000) +# --init : crée la table adsb.table_size_history si elle n'existe pas +# ═══════════════════════════════════════════════════════════════════════════════ + +# ── Configuration ───────────────────────────────────────────────────────────── +PG_USER="pgz_admin" +PG_DB="wpz_postgres" +PG_SCHEMA="adsb" +ALERT_THRESHOLD_MB=5000 + +# ── Couleurs ────────────────────────────────────────────────────────────────── +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' + +ok() { echo -e " ${GREEN}✓${NC} $1"; } +fail() { echo -e " ${RED}✗${NC} $1"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1"; } +info() { echo -e " ${CYAN}→${NC} $1"; } +hdr() { echo -e "\n${BLUE}${BOLD}── $1 ──${NC}"; } + +# ── Parsing des arguments ───────────────────────────────────────────────────── +DO_INIT=false +while [[ $# -gt 0 ]]; do + case "$1" in + --alert) ALERT_THRESHOLD_MB="$2"; shift 2 ;; + --init) DO_INIT=true; shift ;; + *) echo "Usage: $0 [--alert THRESHOLD_MB] [--init]"; exit 1 ;; + esac +done + +# ── Helper psql ─────────────────────────────────────────────────────────────── +pg() { docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -t -c "$1" 2>/dev/null | xargs; } + +# ── Vérification Docker ─────────────────────────────────────────────────────── +if ! docker inspect wordpress-postgres &>/dev/null; then + fail "Conteneur wordpress-postgres inaccessible" + exit 1 +fi + +# ── Init : création de la table d'historique ────────────────────────────────── +if [ "$DO_INIT" = true ]; then + echo -e "${BOLD}Création de adsb.table_size_history...${NC}" + TMP_INIT=$(mktemp /tmp/pg_init_XXXXXX.sql) + trap "rm -f $TMP_INIT" EXIT + cat > "$TMP_INIT" << 'ENDINIT' +CREATE TABLE IF NOT EXISTS adsb.table_size_history ( + id SERIAL PRIMARY KEY, + captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + table_name VARCHAR(60) NOT NULL, + total_bytes BIGINT NOT NULL, + table_bytes BIGINT NOT NULL, + index_bytes BIGINT NOT NULL, + row_estimate BIGINT NOT NULL, + partition_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS tsh_captured_at_idx ON adsb.table_size_history (captured_at DESC); +CREATE INDEX IF NOT EXISTS tsh_table_name_idx ON adsb.table_size_history (table_name, captured_at DESC); +COMMENT ON TABLE adsb.table_size_history IS 'Historique tailles tables ADS-B — monitor_postgres_tables.sh'; +ENDINIT + docker cp "$TMP_INIT" wordpress-postgres:/tmp/pg_init.sql 2>/dev/null + docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -f /tmp/pg_init.sql 2>&1 + RET=$? + docker exec wordpress-postgres rm -f /tmp/pg_init.sql 2>/dev/null + rm -f "$TMP_INIT" + if [ $RET -eq 0 ]; then + ok "Table adsb.table_size_history créée (ou déjà existante)" + else + fail "Erreur lors de la création de la table" + exit 1 + fi + echo "" +fi + +# ── En-tête ─────────────────────────────────────────────────────────────────── +NOW=$(date '+%Y-%m-%d %H:%M:%S') +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Surveillance tailles tables ADS-B PostgreSQL ║${NC}" +echo -e "${BOLD}║ $NOW ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + +# ── Collecte des métriques via une seule requête ────────────────────────────── +hdr "Collecte des métriques" + +# Écrire la requête dans un fichier temporaire (heredoc dans $() est instable en bash) +TMP_SQL=$(mktemp /tmp/pg_monitor_XXXXXX.sql) +trap "rm -f $TMP_SQL" EXIT + +cat > "$TMP_SQL" << 'ENDSQL' +WITH parent_tables AS ( + SELECT + pt.relname AS table_name, + pg_total_relation_size(pt.oid) AS total_bytes, + pg_relation_size(pt.oid) AS table_bytes, + pg_indexes_size(pt.oid) AS index_bytes, + COALESCE(( + SELECT SUM(child.reltuples::BIGINT) + FROM pg_inherits inh + JOIN pg_class child ON child.oid = inh.inhrelid + JOIN pg_class parent ON parent.oid = inh.inhparent + WHERE parent.relname = pt.relname + AND parent.relnamespace = pt.relnamespace + ), pt.reltuples::BIGINT) AS row_estimate, + (SELECT COUNT(*) FROM pg_inherits WHERE inhparent = pt.oid) AS partition_count + FROM pg_class pt + JOIN pg_namespace ns ON ns.oid = pt.relnamespace + WHERE ns.nspname = 'adsb' + AND pt.relkind IN ('r','p') + AND pt.relname IN ('positions', 'aircraft_history', 'receivers') +), +aggregated AS ( + SELECT + pt.table_name, + CASE WHEN pt.partition_count > 0 THEN COALESCE(( + SELECT SUM(pg_total_relation_size(child.oid)) + FROM pg_inherits inh + JOIN pg_class child ON child.oid = inh.inhrelid + JOIN pg_class parent ON parent.oid = inh.inhparent + WHERE parent.relname = pt.table_name + AND parent.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname='adsb') + ), 0) ELSE pt.total_bytes END AS total_bytes, + CASE WHEN pt.partition_count > 0 THEN COALESCE(( + SELECT SUM(pg_relation_size(child.oid)) + FROM pg_inherits inh + JOIN pg_class child ON child.oid = inh.inhrelid + JOIN pg_class parent ON parent.oid = inh.inhparent + WHERE parent.relname = pt.table_name + AND parent.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname='adsb') + ), 0) ELSE pt.table_bytes END AS table_bytes, + CASE WHEN pt.partition_count > 0 THEN COALESCE(( + SELECT SUM(pg_indexes_size(child.oid)) + FROM pg_inherits inh + JOIN pg_class child ON child.oid = inh.inhrelid + JOIN pg_class parent ON parent.oid = inh.inhparent + WHERE parent.relname = pt.table_name + AND parent.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname='adsb') + ), 0) ELSE pt.index_bytes END AS index_bytes, + pt.row_estimate, + pt.partition_count + FROM parent_tables pt +) +SELECT + table_name, + total_bytes, + table_bytes, + index_bytes, + row_estimate, + partition_count, + pg_size_pretty(total_bytes) AS total_pretty, + pg_size_pretty(table_bytes) AS data_pretty, + pg_size_pretty(index_bytes) AS idx_pretty, + ROUND(total_bytes / 1024.0 / 1024.0, 1) AS total_mb +FROM aggregated +ORDER BY total_bytes DESC; +ENDSQL + +# Copier le fichier SQL dans le conteneur et exécuter +docker cp "$TMP_SQL" wordpress-postgres:/tmp/pg_monitor.sql 2>/dev/null +METRICS=$(docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -t -f /tmp/pg_monitor.sql 2>&1) +docker exec wordpress-postgres rm -f /tmp/pg_monitor.sql 2>/dev/null + +if [ -z "$METRICS" ]; then + fail "Impossible de collecter les métriques PostgreSQL" + exit 1 +fi +ok "Métriques collectées" + +# ── Affichage et insertion ──────────────────────────────────────────────────── +hdr "Tailles actuelles" + +ALERT_MSGS=() + +while IFS='|' read -r table_name total_bytes table_bytes index_bytes row_estimate partition_count total_pretty data_pretty idx_pretty total_mb; do + # Nettoyer les espaces + table_name=$(echo "$table_name" | xargs) + total_bytes=$(echo "$total_bytes" | xargs) + table_bytes=$(echo "$table_bytes" | xargs) + index_bytes=$(echo "$index_bytes" | xargs) + row_estimate=$(echo "$row_estimate" | xargs) + partition_count=$(echo "$partition_count" | xargs) + total_pretty=$(echo "$total_pretty" | xargs) + data_pretty=$(echo "$data_pretty" | xargs) + idx_pretty=$(echo "$idx_pretty" | xargs) + total_mb=$(echo "$total_mb" | xargs) + + [ -z "$table_name" ] && continue + + # Récupérer la mesure précédente + PREV=$(docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -t -c " + SELECT total_bytes, captured_at::TEXT + FROM adsb.table_size_history + WHERE table_name = '$table_name' + ORDER BY captured_at DESC + LIMIT 1;" 2>/dev/null | xargs) + + PREV_BYTES=$(echo "$PREV" | awk '{print $1}') + PREV_DATE=$(echo "$PREV" | awk '{print $2, $3}') + + # Calculer la croissance + GROWTH_STR="" + if [ -n "$PREV_BYTES" ] && [ "$PREV_BYTES" -gt 0 ] 2>/dev/null; then + GROWTH_BYTES=$(( total_bytes - PREV_BYTES )) + GROWTH_MB=$(awk "BEGIN{printf \"%.1f\", $GROWTH_BYTES/1024/1024}") + GROWTH_PCT=$(awk "BEGIN{printf \"%.2f\", ($GROWTH_BYTES/$PREV_BYTES)*100}") + if [ "$GROWTH_BYTES" -gt 0 ]; then + GROWTH_STR=" ${GREEN}(+${GROWTH_MB} MB / +${GROWTH_PCT}% depuis $PREV_DATE)${NC}" + elif [ "$GROWTH_BYTES" -lt 0 ]; then + ABS_MB=$(awk "BEGIN{printf \"%.1f\", -$GROWTH_BYTES/1024/1024}") + GROWTH_STR=" ${YELLOW}(-${ABS_MB} MB depuis $PREV_DATE)${NC}" + else + GROWTH_STR=" ${CYAN}(stable depuis $PREV_DATE)${NC}" + fi + fi + + # Affichage + echo -e " ${BOLD}${table_name}${NC}" + echo -e " Total : ${BOLD}${total_pretty}${NC}${GROWTH_STR}" + echo -e " Données : ${data_pretty} | Index : ${idx_pretty}" + echo -e " Lignes : ~$(printf "%'d" "${row_estimate:-0}" 2>/dev/null || echo "$row_estimate") | Partitions : ${partition_count}" + echo "" + + # Alerte seuil + TOTAL_MB_INT=$(echo "$total_mb" | cut -d. -f1) + if [ "${TOTAL_MB_INT:-0}" -gt "$ALERT_THRESHOLD_MB" ] 2>/dev/null; then + ALERT_MSGS+=("$table_name : ${total_pretty} dépasse le seuil de ${ALERT_THRESHOLD_MB} MB") + fi + + # Insertion dans l'historique + INSERT_RESULT=$(docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -t -c " + INSERT INTO adsb.table_size_history + (table_name, total_bytes, table_bytes, index_bytes, row_estimate, partition_count) + VALUES + ('$table_name', $total_bytes, $table_bytes, $index_bytes, + ${row_estimate:-0}, ${partition_count:-0}) + RETURNING id;" 2>/dev/null | xargs) + + if [ -n "$INSERT_RESULT" ]; then + info "Enregistrement #${INSERT_RESULT} inséré dans adsb.table_size_history" + else + warn "Impossible d'insérer dans adsb.table_size_history (table créée ? Lancer avec --init)" + fi + +done <<< "$METRICS" + +# ── Évolution sur 7 jours ───────────────────────────────────────────────────── +hdr "Évolution sur 7 jours" + +docker exec wordpress-postgres psql -U "$PG_USER" -d "$PG_DB" -c " +SELECT + table_name AS \"Table\", + TO_CHAR(captured_at, 'YYYY-MM-DD HH24:MI') AS \"Horodatage\", + pg_size_pretty(total_bytes) AS \"Total\", + pg_size_pretty(total_bytes + - LAG(total_bytes) OVER (PARTITION BY table_name ORDER BY captured_at)) + AS \"Delta\", + partition_count AS \"Parts\", + TO_CHAR(row_estimate, 'FM999,999,999,999') AS \"Lignes estimées\" +FROM adsb.table_size_history +WHERE captured_at >= NOW() - INTERVAL '7 days' + AND table_name IN ('positions','aircraft_history') +ORDER BY table_name, captured_at DESC +LIMIT 30;" 2>/dev/null || warn "Table adsb.table_size_history absente — lancer avec --init" + +# ── Alertes ─────────────────────────────────────────────────────────────────── +if [ ${#ALERT_MSGS[@]} -gt 0 ]; then + echo "" + echo -e "${RED}${BOLD}═══ ALERTES ═══${NC}" + for msg in "${ALERT_MSGS[@]}"; do + echo -e " ${RED}⚠ $msg${NC}" + done +fi + +# ── Résumé ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Historique : SELECT * FROM adsb.table_size_history ║${NC}" +echo -e "${BOLD}║ Seuil alerte : ${ALERT_THRESHOLD_MB} MB ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + diff --git a/postgres/conf b/postgres/conf new file mode 120000 index 0000000..0f637fe --- /dev/null +++ b/postgres/conf @@ -0,0 +1 @@ +/var/snap/docker/common/var-lib-docker/volumes/pg-conf-std/_data \ No newline at end of file diff --git a/postgres/logs b/postgres/logs new file mode 120000 index 0000000..83d5cbf --- /dev/null +++ b/postgres/logs @@ -0,0 +1 @@ +/var/snap/docker/common/var-lib-docker/volumes/pg-logs-std/_data \ No newline at end of file diff --git a/queries.sql b/queries.sql new file mode 100644 index 0000000..f340699 --- /dev/null +++ b/queries.sql @@ -0,0 +1,65 @@ +-- Requêtes utiles pour explorer les données ADS-B +-- Base : wpz_postgres, schéma : adsb + +-- Nombre de positions par jour (7 derniers jours) +SELECT + DATE_TRUNC('day', ts AT TIME ZONE 'Europe/Paris') AS jour, + COUNT(*) AS nb_positions, + COUNT(DISTINCT icao) AS nb_avions +FROM adsb.positions +WHERE ts >= NOW() - INTERVAL '7 days' +GROUP BY 1 +ORDER BY 1 DESC; + +-- Avions les plus vus aujourd'hui +SELECT + icao, + MAX(callsign) AS callsign, + COUNT(*) AS nb_positions, + MIN(altitude) AS alt_min, + MAX(altitude) AS alt_max, + ROUND(AVG(speed)) AS vitesse_moy, + MIN(ts) AS premiere_vue, + MAX(ts) AS derniere_vue +FROM adsb.positions +WHERE ts >= DATE_TRUNC('day', NOW() AT TIME ZONE 'Europe/Paris') +GROUP BY icao +ORDER BY nb_positions DESC +LIMIT 20; + +-- Trajectoire d'un avion spécifique (remplacer l'ICAO) +SELECT ts, callsign, altitude, speed, track, lat, lon, vrate +FROM adsb.positions +WHERE icao = '440AF4' + AND ts >= NOW() - INTERVAL '24 hours' +ORDER BY ts; + +-- Avions en dessous de 5000 ft (approche Orly/CDG) +SELECT DISTINCT ON (icao) + icao, callsign, altitude, speed, lat, lon, ts +FROM adsb.positions +WHERE ts >= NOW() - INTERVAL '1 hour' + AND altitude IS NOT NULL + AND altitude < 5000 +ORDER BY icao, ts DESC; + +-- Statistiques globales +SELECT + COUNT(*) AS total_positions, + COUNT(DISTINCT icao) AS avions_distincts, + MIN(ts) AS depuis, + MAX(ts) AS jusqua, + ROUND(AVG(altitude)) AS altitude_moyenne, + ROUND(AVG(speed)) AS vitesse_moyenne +FROM adsb.positions; + +-- Taille des partitions +SELECT + c.relname AS partition, + pg_size_pretty(pg_relation_size(c.oid)) AS taille, + pg_size_pretty(pg_total_relation_size(c.oid)) AS taille_totale +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'adsb' + AND c.relkind = 'r' +ORDER BY pg_relation_size(c.oid) DESC; diff --git a/scripts/detect_dongles.sh b/scripts/detect_dongles.sh new file mode 100755 index 0000000..2aee6cf --- /dev/null +++ b/scripts/detect_dongles.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════════ +# detect_dongles.sh v1.1 — source de vérité unique sur les récepteurs ADS-B +# Emplacement : /data/adsb/scripts/detect_dongles.sh +# +# Se source (ne s'exécute pas). Fournit : +# SLOTS[] catalogue statique des slots (ports, conteneurs, profile) +# MAX_RECEIVERS nombre de slots disponibles +# detect_serials() un numéro de série par ligne, ordre d'énumération USB +# detect_count() nombre de dongles présents +# slot_ports() calcule beast/raw/sbs pour un numéro de slot +# +# v1.1 (31/07/2026) : SLOTS remonté ici pour être partagé par check_adsb.sh et +# watchdog_readsb.sh — la duplication du catalogue entre les deux scripts était +# une source de désynchronisation. +# +# detect_serials fonctionne même si un dongle est déjà « claim » par un +# conteneur : rtl_test énumère AVANT de tenter l'ouverture, et l'erreur +# usb_claim_interface -6 qui suit n'affecte pas la liste produite. +# ═══════════════════════════════════════════════════════════════════════════════ + +# slot:port_tar1090:ctn_readsb:ctn_tar1090:ctn_adsb2pg:profile +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_serials() { + if command -v rtl_test >/dev/null 2>&1; then + rtl_test -t 2>&1 \ + | sed -n 's/^[[:space:]]*[0-9]\+:.*SN:[[:space:]]*\([0-9A-Za-z]\+\).*/\1/p' + else + local n i + n=$(lsusb 2>/dev/null | grep -ci 'RTL2838\|RTL2832') + for ((i = 1; i <= n; i++)); do echo "unknown${i}"; done + fi +} + +detect_count() { detect_serials | grep -c . ; } + +# slot_ports → écrit "beast raw sbs" +slot_ports() { + local n=$1 + echo "$((30005 + (n - 1) * 10)) $((30002 + (n - 1) * 10)) $((30003 + (n - 1) * 10))" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + mapfile -t _s < <(detect_serials) + echo "${#_s[@]} dongle(s) RTL-SDR détecté(s)" + for i in "${!_s[@]}"; do echo " slot rx$((i + 1)) → SN ${_s[$i]}"; done +fi diff --git a/scripts/fetch_flags.sh b/scripts/fetch_flags.sh new file mode 100755 index 0000000..4263524 --- /dev/null +++ b/scripts/fetch_flags.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════════ +# fetch_flags.sh — récupère les drapeaux en local, pour ne plus dépendre du CDN +# +# Par défaut la webapp charge les drapeaux depuis flagcdn.com (domaine public, +# libre d'usage, sans attribution). Ce script les rapatrie dans +# webapp/src/assets/flags/ pour un fonctionnement entièrement hors ligne. +# +# Après exécution, passer FLAG_LOCAL à true dans js/app.js : +# sed -i 's/const FLAG_LOCAL = false;/const FLAG_LOCAL = true;/' \ +# /data/adsb/webapp/src/js/app.js +# +# Poids total : environ 200 Ko pour ~100 pays. +# +# Licence : les drapeaux nationaux sont des symboles d'État, non couverts par +# le droit d'auteur. flagcdn.com les distribue en domaine public. +# ═══════════════════════════════════════════════════════════════════════════════ +set -euo pipefail + +DEST="${1:-/data/adsb/webapp/src/assets/flags}" +BASE="https://flagcdn.com/24x18" + +# Codes ISO alignés sur la table COUNTRY_ISO de app.js +CODES="fr de es it pt be nl lu ch at gb ie dk no se fi is pl cz sk hu ro bg gr +hr si rs ba al mt cy ee lv lt ua by ru tr us ca mx br ar cl co pe ve cn jp kr +in pk th vn sg my id ph au nz il sa qa ae kw bh om jo lb eg ma dz tn ly za ng +ke et gh sn ci cm ga mc sm ad li im gg je bm ky aw bs pa cr do cu jm kz uz az +ge am md me mk ir iq sy af bd lk np mm kh la mn tw hk mo" + +mkdir -p "$DEST" + +ok=0; ko=0 +for c in $CODES; do + if [ -s "$DEST/$c.png" ]; then + ok=$((ok+1)); continue + fi + if curl -fsSL --max-time 10 "$BASE/$c.png" -o "$DEST/$c.png" 2>/dev/null; then + ok=$((ok+1)) + else + rm -f "$DEST/$c.png" + echo " échec : $c" >&2 + ko=$((ko+1)) + fi +done + +echo "$ok drapeau(x) disponible(s) dans $DEST" +[ "$ko" -gt 0 ] && echo "$ko échec(s) — ces pays retomberont sur le pictogramme « inconnu »" + +echo +echo "Pour activer l'hébergement local :" +echo " sed -i 's/const FLAG_LOCAL = false;/const FLAG_LOCAL = true;/' \\" +echo " /data/adsb/webapp/src/js/app.js" +echo " docker cp /data/adsb/webapp/src/js/app.js adsb-webapp:/var/www/html/js/app.js" diff --git a/scripts/gen_env.sh b/scripts/gen_env.sh new file mode 100755 index 0000000..ed969b7 --- /dev/null +++ b/scripts/gen_env.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════════ +# gen_env.sh — génère /data/adsb/.env.receivers à partir des dongles présents +# Emplacement : /data/adsb/scripts/gen_env.sh +# +# Produit : +# RX_SERIAL numéro de série affecté au slot n +# RX_GAIN gain du slot n (repris de .env.gains si présent, sinon autogain) +# COMPOSE_PROFILES liste des profiles à activer (rx1[,rx2[,rx3]]) +# +# Usage : +# /data/adsb/scripts/gen_env.sh +# docker compose --env-file .env.receivers up -d --remove-orphans +# +# Le --remove-orphans est OBLIGATOIRE : Compose ne supprime pas de lui-même les +# conteneurs d'un profile devenu inactif. Sans lui, un readsb dont le dongle a +# été débranché continue de tourner à vide et de disputer le bus USB. +# ═══════════════════════════════════════════════════════════════════════════════ +set -euo pipefail + +BASE_DIR="/data/adsb" +ENV_FILE="${BASE_DIR}/.env.receivers" +GAINS_FILE="${BASE_DIR}/.env.gains" # optionnel : RX1_GAIN=49.6 etc. +MAX_RECEIVERS=3 + +# shellcheck source=/dev/null +source "${BASE_DIR}/scripts/detect_dongles.sh" + +mapfile -t SERIALS < <(detect_serials) + +# Gains persistants par slot (facultatif) +declare -A GAINS=() +if [ -r "$GAINS_FILE" ]; then + while IFS='=' read -r k v; do + [[ "$k" =~ ^RX[0-9]+_GAIN$ ]] && GAINS["$k"]="$v" + done < "$GAINS_FILE" +fi + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +{ + echo "# Généré par gen_env.sh le $(date '+%Y-%m-%d %H:%M:%S') — NE PAS ÉDITER" + echo "# Dongles détectés : ${#SERIALS[@]}" +} > "$TMP" + +PROFILES=() +for i in "${!SERIALS[@]}"; do + n=$((i + 1)) + if [ "$n" -gt "$MAX_RECEIVERS" ]; then + echo "AVERTISSEMENT : ${#SERIALS[@]} dongles détectés, seuls $MAX_RECEIVERS slots existent — ${SERIALS[$i]} ignoré" >&2 + break + fi + echo "RX${n}_SERIAL=${SERIALS[$i]}" >> "$TMP" + echo "RX${n}_GAIN=${GAINS[RX${n}_GAIN]:-autogain}" >> "$TMP" + PROFILES+=("rx${n}") +done + +# Les slots non pourvus doivent quand même exister comme variables, sinon +# Compose émet un warning à l'interpolation même pour un service désactivé. +for ((n = ${#PROFILES[@]} + 1; n <= MAX_RECEIVERS; n++)); do + echo "RX${n}_SERIAL=" >> "$TMP" + echo "RX${n}_GAIN=autogain" >> "$TMP" +done + +if [ ${#PROFILES[@]} -eq 0 ]; then + echo "COMPOSE_PROFILES=" >> "$TMP" + echo "ERREUR : aucun dongle RTL-SDR détecté — aucun récepteur ne sera démarré" >&2 +else + printf 'COMPOSE_PROFILES=%s\n' "$(IFS=,; echo "${PROFILES[*]}")" >> "$TMP" +fi + +install -m 0640 "$TMP" "$ENV_FILE" +cat "$ENV_FILE" diff --git a/scripts/watchdog_readsb.sh b/scripts/watchdog_readsb.sh new file mode 100755 index 0000000..57308b2 --- /dev/null +++ b/scripts/watchdog_readsb.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# ============================================================================= +# watchdog_readsb.sh — surveille le(s) récepteur(s) ADS-B et redémarre le +# maillon réellement en cause quand le flux de messages se fige. +# +# v4 (31/07/2026) — refonte après l'incident du 31/07 (36 redémarrages en 24h +# sur un récepteur qui n'était pas en panne). Quatre défauts corrigés : +# +# 1. RECEIVERS était statique et listait un dongle inexistant (00000012). +# Le watchdog redémarrait donc en boucle un conteneur sans matériel. +# → RECEIVERS est maintenant dérivé des dongles réellement énumérés, +# via detect_dongles.sh (même source que check_adsb.sh). +# +# 2. THRESHOLD=1 le jour : redémarrage après 5 min sans progression. Or le +# service autogain a besoin de plusieurs heures de trafic pour converger, +# et chaque redémarrage le relançait de zéro. Le watchdog empêchait donc +# la seule chose qui aurait corrigé le gain (bloqué à 7.7 dB). +# → seuil minimum 3 cycles, + délai de grâce après démarrage. +# +# 3. Aucun plafond : un redémarrage qui ne corrige rien était répété +# indéfiniment, noyant le vrai diagnostic sous le bruit. +# → budget de MAX_RESTARTS_24H, au-delà duquel on alerte sans agir. +# +# 4. Le diagnostic reposait uniquement sur aircraft.json (servi par tar1090). +# Un tar1090 déconnecté de readsb produisait donc le même symptôme qu'un +# dongle mort — c'est exactement ce qui s'est produit le 31/07 après une +# perte d'alias réseau : readsb décodait, tar1090 servait 0 message, et +# le watchdog redémarrait readsb, le maillon sain. +# → sonde directe du port SBS : si readsb émet, c'est tar1090 qu'on +# redémarre, pas readsb. +# +# Cron : */5 * * * * /data/adsb/scripts/watchdog_readsb.sh +# ============================================================================= + +BASE_DIR="/data/adsb" +LOG_FILE="/var/log/readsb_watchdog.log" +DETECT_LIB="${BASE_DIR}/scripts/detect_dongles.sh" + +# ── Paramètres de prudence ──────────────────────────────────────────────────── +MIN_UPTIME=1800 # s — ne jamais redémarrer un conteneur démarré depuis + # moins de 30 min (laisse converger l'autogain) +MAX_RESTARTS_24H=6 # au-delà, on alerte sans redémarrer : si 6 redémarrages + # n'ont pas corrigé le problème, le 7e ne le fera pas +SBS_PROBE_TIMEOUT=6 # s — durée de la sonde sur le port SBS +SBS_MIN_BYTES=50 # octets reçus au-delà desquels readsb est jugé actif + +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $*" >> "$LOG_FILE"; } + +# ── Seuil adaptatif jour/nuit ───────────────────────────────────────────────── +# Le creux de trafic nocturne reste réel : on tolère plus longtemps la nuit. +# Mais le plancher est désormais à 3 cycles (15 min) même en journée. +HOUR=$(date +%H) +if [ "$((10#$HOUR))" -ge 6 ] && [ "$((10#$HOUR))" -lt 22 ]; then + THRESHOLD=3; PERIOD="jour" +else + THRESHOLD=5; PERIOD="nuit" +fi + +# ── Construction dynamique des récepteurs ───────────────────────────────────── +if [ ! -r "$DETECT_LIB" ]; then + log "[FATAL] $DETECT_LIB introuvable — watchdog inopérant" + exit 1 +fi +# shellcheck source=/dev/null +source "$DETECT_LIB" + +mapfile -t PRESENT_SERIALS < <(detect_serials) + +if [ "${#PRESENT_SERIALS[@]}" -eq 0 ]; then + log "[ALERTE] Aucun dongle RTL-SDR détecté sur le bus USB — rien à surveiller. Vérifier le branchement physique." + exit 0 +fi + +# ── Budget de redémarrages ──────────────────────────────────────────────────── +# Historique des redémarrages par conteneur, élagué à 24h glissantes. +restart_count_24h() { + local ctn="$1" f="/tmp/readsb_restart_hist_${ctn}" now cutoff + now=$(date +%s); cutoff=$((now - 86400)) + [ -f "$f" ] || { echo 0; return; } + awk -v c="$cutoff" '$1 >= c' "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" + wc -l < "$f" | tr -d ' ' +} + +record_restart() { + echo "$(date +%s)" >> "/tmp/readsb_restart_hist_$1" +} + +# ── Uptime d'un conteneur, en secondes ──────────────────────────────────────── +container_uptime() { + local started + started=$(docker inspect --format '{{.State.StartedAt}}' "$1" 2>/dev/null) || return 1 + [ -z "$started" ] && return 1 + echo $(( $(date +%s) - $(date -d "$started" +%s 2>/dev/null || echo 0) )) +} + +container_running() { + [ "$(docker inspect --format '{{.State.Status}}' "$1" 2>/dev/null)" = "running" ] +} + +# ── Sonde SBS : readsb émet-il réellement des messages décodés ? ─────────────── +# Retourne 0 si du trafic ADS-B sort du récepteur lui-même. +sbs_has_traffic() { + local port="$1" bytes + if command -v nc >/dev/null 2>&1; then + bytes=$(timeout "$SBS_PROBE_TIMEOUT" nc 127.0.0.1 "$port" 2>/dev/null \ + | head -c 400 | wc -c) + else + bytes=$(timeout "$SBS_PROBE_TIMEOUT" bash -c \ + "exec 3<>/dev/tcp/127.0.0.1/$port && head -c 400 <&3" 2>/dev/null | wc -c) + fi + [ "${bytes:-0}" -ge "$SBS_MIN_BYTES" ] +} + +# ── Redémarrage encadré ─────────────────────────────────────────────────────── +guarded_restart() { + local ctn="$1" reason="$2" up n + + if ! container_running "$ctn"; then + log "[$ctn] non démarré — pas de redémarrage watchdog (relève de docker compose / adsb-stack.service)" + return 1 + fi + + up=$(container_uptime "$ctn") + if [ -n "$up" ] && [ "$up" -lt "$MIN_UPTIME" ]; then + log "[$ctn] $reason — mais uptime ${up}s < ${MIN_UPTIME}s, on laisse converger (autogain / initialisation)" + return 1 + fi + + n=$(restart_count_24h "$ctn") + if [ "$n" -ge "$MAX_RESTARTS_24H" ]; then + log "[ALERTE] [$ctn] $reason — $n redémarrages sur 24h, plafond atteint : AUCUNE action. Le redémarrage ne corrige pas ce problème, diagnostic manuel requis (gain, antenne, câble, alias réseau)." + return 1 + fi + + log "[$ctn] $reason — redémarrage ($((n + 1))/${MAX_RESTARTS_24H} sur 24h)" + docker restart "$ctn" >/dev/null 2>&1 && record_restart "$ctn" + return 0 +} + +# ── Boucle principale ───────────────────────────────────────────────────────── +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]}" + RX_SERIAL="${PRESENT_SERIALS[$i]}" + read -r BEAST_PORT RAW_PORT SBS_PORT <<< "$(slot_ports "$SLOT")" + + STATE_FILE="/tmp/readsb_last_msg_count_${RX_SERIAL}" + STREAK_FILE="/tmp/readsb_stall_streak_${RX_SERIAL}" + + # Le conteneur readsb doit exister ; sinon rien à surveiller sur ce slot. + if ! docker inspect "$CTN_RS" >/dev/null 2>&1; then + log "[$CTN_RS] conteneur absent alors que le dongle $RX_SERIAL est présent — le stack n'est pas déployé pour ce slot (docker compose up -d --remove-orphans)" + continue + fi + + CURRENT=$(curl -s --max-time 10 "http://127.0.0.1:${PORT}/data/aircraft.json" \ + | grep -oP '"messages"\s*:\s*\K[0-9]+') + + if [ -z "$CURRENT" ]; then + log "[$CTN_RS] aircraft.json illisible (port ${PORT}) — tar1090 muet ; check ignoré ce cycle" + continue + fi + + LAST=$(cat "$STATE_FILE" 2>/dev/null || echo -1) + STREAK=$(cat "$STREAK_FILE" 2>/dev/null || echo 0) + + if [ "$CURRENT" == "$LAST" ]; then + STREAK=$((STREAK + 1)) + + if [ "$STREAK" -ge "$THRESHOLD" ]; then + # Compteur figé confirmé. Avant d'accuser readsb, on demande + # directement au récepteur s'il émet : c'est ce test qui distingue + # un dongle sourd d'un tar1090 déconnecté. + if sbs_has_traffic "$SBS_PORT"; then + log "[$CTN_RS] compteur tar1090 figé à $CURRENT depuis ${STREAK} cycle(s) MAIS le port SBS ${SBS_PORT} émet — readsb est sain, le maillon en cause est tar1090" + guarded_restart "$CTN_T1090" "aircraft.json figé alors que readsb émet (alias réseau BEASTHOST perdu ?)" + else + guarded_restart "$CTN_RS" "compteur messages figé à $CURRENT depuis ${STREAK} cycle(s) [$PERIOD, seuil=$THRESHOLD] et port SBS ${SBS_PORT} silencieux" + fi + STREAK=0 + else + log "[$CTN_RS] compteur inchangé à $CURRENT (${STREAK}/${THRESHOLD} cycles) [$PERIOD] - en observation" + fi + else + # Progression : on efface aussi l'historique de redémarrages, le + # récepteur est manifestement revenu à la normale. + if [ "$STREAK" -gt 0 ]; then + log "[$CTN_RS] flux rétabli ($LAST → $CURRENT) - compteur de blocage remis à zéro" + fi + STREAK=0 + rm -f "/tmp/readsb_restart_hist_${CTN_RS}" "/tmp/readsb_restart_hist_${CTN_T1090}" + fi + + echo "$CURRENT" > "$STATE_FILE" + echo "$STREAK" > "$STREAK_FILE" +done diff --git a/sql/01_audit_partitions.sql b/sql/01_audit_partitions.sql new file mode 100644 index 0000000..8bea91c --- /dev/null +++ b/sql/01_audit_partitions.sql @@ -0,0 +1,63 @@ +-- ============================================================================= +-- 01_audit_partitions.sql — inventaire RÉEL des partitions de adsb.positions +-- +-- Lecture seule. Ne modifie rien. +-- +-- reltuples = -1 signifie « jamais analysée » en PostgreSQL 16, PAS « vide ». +-- Une somme à -16 (16 enfants × -1) est donc un compte INCONNU. Ce script +-- compte les lignes pour de vrai avant toute décision de suppression. +-- +-- Usage : +-- docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ +-- -v ON_ERROR_STOP=1 < 01_audit_partitions.sql +-- ============================================================================= + +\timing on + +-- Vue d'ensemble +SELECT c.relkind, + count(*) AS nb, + CASE c.relkind WHEN 'r' THEN 'RANGE simple (cible)' + WHEN 'p' THEN 'encore sous-partitionnée HASH' END AS etat +FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid +WHERE i.inhparent = 'adsb.positions'::regclass +GROUP BY 1, 3 +ORDER BY 1; + +-- Comptage réel de chaque partition de type 'p'. +-- Sur ~127 partitions dont la plupart sont vides, l'opération est rapide : +-- un count(*) sur une partition vide ne lit aucune page. +CREATE TEMP TABLE audit_parts AS +SELECT c.relname::text AS partition, + pg_get_expr(c.relpartbound, c.oid) AS bornes, + 0::bigint AS lignes +FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid +WHERE i.inhparent = 'adsb.positions'::regclass + AND c.relkind = 'p'; + +DO $$ +DECLARE r record; n bigint; +BEGIN + FOR r IN SELECT partition FROM audit_parts LOOP + EXECUTE format('SELECT count(*) FROM adsb.%I', r.partition) INTO n; + UPDATE audit_parts SET lignes = n WHERE partition = r.partition; + END LOOP; +END $$; + +-- Synthèse +SELECT count(*) FILTER (WHERE lignes = 0) AS partitions_vides, + count(*) FILTER (WHERE lignes > 0) AS partitions_avec_donnees, + sum(lignes) AS lignes_totales_a_migrer +FROM audit_parts; + +-- Détail des partitions contenant des données (à migrer avec copie) +SELECT partition, bornes, lignes +FROM audit_parts +WHERE lignes > 0 +ORDER BY bornes; + +-- Bornes extrêmes des partitions vides (à recréer sans copie) +SELECT min(bornes) AS premiere_vide, max(bornes) AS derniere_vide +FROM audit_parts WHERE lignes = 0; + +\timing off diff --git a/sql/02_convert_partitions_vides.sql b/sql/02_convert_partitions_vides.sql new file mode 100644 index 0000000..87af925 --- /dev/null +++ b/sql/02_convert_partitions_vides.sql @@ -0,0 +1,98 @@ +-- ============================================================================= +-- 02_convert_partitions_vides.sql — supprime les partitions HASH VIDES et les +-- recrée en RANGE simple, avec leurs index. +-- +-- Sans risque de perte : chaque partition est recomptée juste avant sa +-- suppression, et toute partition non vide est IGNORÉE (jamais supprimée). +-- C'est le garde-fou principal de ce script. +-- +-- Gain attendu : ~2000 sous-partitions HASH en moins, donc un temps de +-- planification qui retombe de ~1500 ms à quelques millisecondes. L'exécution +-- des requêtes, elle, était déjà à ~5 ms — c'est bien la planification qui +-- coûte, car le planner doit ouvrir toutes les partitions pour les élaguer. +-- +-- Usage OBLIGATOIRE avec ON_ERROR_STOP : sans ce flag, psql poursuit après une +-- erreur et pourrait exécuter un DROP alors que le contrôle a échoué. +-- docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ +-- -v ON_ERROR_STOP=1 < 02_convert_partitions_vides.sql +-- ============================================================================= + +\timing on +\set ON_ERROR_STOP on + +DO $$ +DECLARE + r record; + n bigint; + d0 date; + d1 date; + tbl text; + converties int := 0; + ignorees int := 0; +BEGIN + FOR r IN + SELECT c.oid, c.relname::text AS partition, + pg_get_expr(c.relpartbound, c.oid) AS bornes + FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'adsb.positions'::regclass + AND c.relkind = 'p' + ORDER BY pg_get_expr(c.relpartbound, c.oid) + LOOP + -- GARDE-FOU : comptage réel immédiatement avant toute action destructive. + EXECUTE format('SELECT count(*) FROM adsb.%I', r.partition) INTO n; + + IF n > 0 THEN + RAISE NOTICE 'IGNORÉE % : % ligne(s) — migration avec copie requise (script 03)', + r.partition, n; + ignorees := ignorees + 1; + CONTINUE; + END IF; + + -- Extraction des bornes réelles depuis relpartbound : on ne se fie pas au + -- nom, qui peut être erroné (bug EXTRACT(YEAR) vs ISOYEAR). + d0 := (regexp_match(r.bornes, 'FROM \(''([0-9-]+)'))[1]::date; + d1 := (regexp_match(r.bornes, 'TO \(''([0-9-]+)'))[1]::date; + tbl := r.partition; + + EXECUTE format('DROP TABLE adsb.%I', tbl); + + EXECUTE format( + 'CREATE TABLE adsb.%I PARTITION OF adsb.positions FOR VALUES FROM (%L) TO (%L)', + tbl, d0, d1); + + -- Index identiques à ceux posés par adsb.create_week_partition + EXECUTE format('CREATE INDEX %I ON adsb.%I USING GIST (geom)', tbl||'_geom_gix', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (ts DESC)', tbl||'_ts_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (icao, ts DESC)', tbl||'_icao_ts_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (inserted_at DESC)', tbl||'_inserted_at_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (callsign) WHERE callsign IS NOT NULL', + tbl||'_callsign_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (snap_id)', tbl||'_snap_id_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (receiver_id)', tbl||'_receiver_id_idx', tbl); + EXECUTE format('CREATE INDEX %I ON adsb.%I (aircraft_wtc)', tbl||'_aircraft_wtc_idx', tbl); + EXECUTE format( + 'ALTER TABLE adsb.%I ADD CONSTRAINT %I FOREIGN KEY (receiver_id) ' + 'REFERENCES adsb.receivers(id) ON DELETE SET NULL', + tbl, tbl||'_receiver_fk'); + + converties := converties + 1; + END LOOP; + + RAISE NOTICE '─────────────────────────────────────────────'; + RAISE NOTICE '% partition(s) converties en RANGE simple', converties; + RAISE NOTICE '% partition(s) ignorées (contiennent des données)', ignorees; +END $$; + +-- Résultat +SELECT c.relkind, count(*) AS nb +FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid +WHERE i.inhparent = 'adsb.positions'::regclass +GROUP BY 1 ORDER BY 1; + +SELECT count(*) AS total_partitions_et_sous_partitions +FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid +WHERE i.inhparent = 'adsb.positions'::regclass + OR i.inhparent IN (SELECT inhrelid FROM pg_inherits + WHERE inhparent = 'adsb.positions'::regclass); + +\timing off diff --git a/sql/03_convert_partition_avec_donnees.sql b/sql/03_convert_partition_avec_donnees.sql new file mode 100644 index 0000000..ad63ece --- /dev/null +++ b/sql/03_convert_partition_avec_donnees.sql @@ -0,0 +1,141 @@ +-- ============================================================================= +-- 03_convert_partition_avec_donnees.sql — migre UNE partition contenant des +-- données, de HASH×16 vers RANGE simple, sans perte. +-- +-- À lancer une partition à la fois, en passant son nom : +-- docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ +-- -v ON_ERROR_STOP=1 -v part=positions_2026_w31 \ +-- < 03_convert_partition_avec_donnees.sql +-- +-- PRÉALABLE OBLIGATOIRE — sauvegarde de la partition concernée : +-- docker exec wordpress-postgres pg_dump -U pgz_admin -d wpz_postgres \ +-- -t "adsb.*" -Fc -f /tmp/.dump +-- docker cp wordpress-postgres:/tmp/.dump /data/adsb/backups/ +-- +-- Principe : +-- 1. DETACH de l'ancienne partition (les données restent, hors du parent) +-- 2. CREATE de la nouvelle partition RANGE simple, attachée +-- 3. INSERT ... SELECT depuis l'ancienne vers le parent +-- 4. Contrôle des comptes AVANT/APRÈS — DROP uniquement si identiques +-- +-- Le tout dans UNE transaction : en cas de problème, ROLLBACK ramène l'état +-- initial. Pendant l'opération, adsb2pg peut continuer d'insérer : ses lignes +-- arrivent dans la nouvelle partition, déjà attachée. +-- +-- NOTE : sur la partition COURANTE, faire l'opération à faible trafic. Les +-- lignes insérées entre le DETACH et le CREATE (quelques millisecondes) +-- provoqueraient une erreur « no partition of relation found » côté adsb2pg, +-- qui les réessaiera au cycle suivant. +-- ============================================================================= + +\timing on +\set ON_ERROR_STOP on + +\if :{?part} +\else + \echo 'ERREUR : passer -v part=' + \quit 1 +\endif + +BEGIN; + +-- Verrou explicite : empêche toute modification concurrente de la structure +LOCK TABLE adsb.positions IN SHARE UPDATE EXCLUSIVE MODE; + +-- Transmission du nom de partition au bloc PL/pgSQL (les variables psql ne +-- sont pas visibles depuis un DO $$ : il faut passer par un paramètre de +-- session). +SET LOCAL my.part = :'part'; + +DO $$ +DECLARE + p_old text := current_setting('my.part'); + p_tmp text := current_setting('my.part') || '_old'; + bornes text; + d0 date; + d1 date; + n_avant bigint; + n_apres bigint; + cols text; +BEGIN + SELECT pg_get_expr(c.relpartbound, c.oid) INTO bornes + FROM pg_class c JOIN pg_namespace ns ON ns.oid = c.relnamespace + WHERE ns.nspname = 'adsb' AND c.relname = p_old; + + IF bornes IS NULL THEN + RAISE EXCEPTION 'Partition adsb.% introuvable ou non attachée', p_old; + END IF; + + d0 := (regexp_match(bornes, 'FROM \(''([0-9-]+)'))[1]::date; + d1 := (regexp_match(bornes, 'TO \(''([0-9-]+)'))[1]::date; + + EXECUTE format('SELECT count(*) FROM adsb.%I', p_old) INTO n_avant; + RAISE NOTICE 'Partition % : % ligne(s) à migrer (bornes % → %)', p_old, n_avant, d0, d1; + + -- 1. Détacher et renommer l'ancienne + EXECUTE format('ALTER TABLE adsb.positions DETACH PARTITION adsb.%I', p_old); + EXECUTE format('ALTER TABLE adsb.%I RENAME TO %I', p_old, p_tmp); + + -- 2. Créer la nouvelle, en RANGE simple + EXECUTE format( + 'CREATE TABLE adsb.%I PARTITION OF adsb.positions FOR VALUES FROM (%L) TO (%L)', + p_old, d0, d1); + + -- 3. Recopier les données. + -- geom est GENERATED ALWAYS : PostgreSQL refuse toute valeur explicite + -- (« cannot insert a non-DEFAULT value into column geom »). Un SELECT * + -- la remonterait, il faut donc énumérer les colonnes réelles et l'exclure. + -- Elle sera recalculée automatiquement depuis lat/lon à l'insertion. + SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) + INTO cols + FROM pg_attribute a + WHERE a.attrelid = format('adsb.%I', p_tmp)::regclass + AND a.attnum > 0 + AND NOT a.attisdropped + AND a.attgenerated = ''; -- exclut les colonnes générées + + IF cols IS NULL OR cols = '' THEN + RAISE EXCEPTION 'Impossible de déterminer les colonnes de adsb.%', p_tmp; + END IF; + + RAISE NOTICE 'Colonnes copiées (geom exclue, recalculée) : %', cols; + + EXECUTE format('INSERT INTO adsb.%I (%s) SELECT %s FROM adsb.%I', + p_old, cols, cols, p_tmp); + + EXECUTE format('SELECT count(*) FROM adsb.%I', p_old) INTO n_apres; + + -- 4. Contrôle : on ne supprime QUE si le compte correspond exactement. + IF n_apres <> n_avant THEN + RAISE EXCEPTION 'ÉCART DE COMPTE : % avant, % après — transaction annulée, aucune donnée perdue', + n_avant, n_apres; + END IF; + + RAISE NOTICE 'Copie vérifiée : % ligne(s) — suppression de l''ancienne structure', n_apres; + EXECUTE format('DROP TABLE adsb.%I', p_tmp); + + -- 5. Index sur la nouvelle partition + EXECUTE format('CREATE INDEX %I ON adsb.%I USING GIST (geom)', p_old||'_geom_gix', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (ts DESC)', p_old||'_ts_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (icao, ts DESC)', p_old||'_icao_ts_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (inserted_at DESC)', p_old||'_inserted_at_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (callsign) WHERE callsign IS NOT NULL', + p_old||'_callsign_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (snap_id)', p_old||'_snap_id_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (receiver_id)', p_old||'_receiver_id_idx', p_old); + EXECUTE format('CREATE INDEX %I ON adsb.%I (aircraft_wtc)', p_old||'_aircraft_wtc_idx', p_old); + EXECUTE format( + 'ALTER TABLE adsb.%I ADD CONSTRAINT %I FOREIGN KEY (receiver_id) ' + 'REFERENCES adsb.receivers(id) ON DELETE SET NULL', + p_old, p_old||'_receiver_fk'); + + RAISE NOTICE 'Partition % convertie en RANGE simple avec ses index', p_old; +END $$; + +COMMIT; + +-- ANALYZE hors transaction : indispensable, la nouvelle partition n'a aucune +-- statistique et le planner ferait de mauvais choix. +ANALYZE adsb.positions; + +\timing off diff --git a/sql/04_airports.sql b/sql/04_airports.sql new file mode 100644 index 0000000..c5d3609 --- /dev/null +++ b/sql/04_airports.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- 04_airports.sql — table de référence des aérodromes d'Île-de-France +-- +-- Sert de liste d'exclusion à l'onglet « Départs hors aérodromes » : tout point +-- de décollage situé dans le rayon d'un aérodrome ACTIF est écarté des +-- résultats. Chaque site a son propre rayon, ajustable indépendamment. +-- +-- Le rayon n'est pas la taille de l'aérodrome mais la zone dans laquelle un +-- avion en montée initiale est encore attribuable à ce terrain. Il est donc +-- plus large pour les grandes plateformes (trajectoires de départ étendues) +-- que pour un héliport. +-- +-- Usage : +-- docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ +-- -v ON_ERROR_STOP=1 < 04_airports.sql +-- ============================================================================= + +\set ON_ERROR_STOP on + +CREATE TABLE IF NOT EXISTS adsb.airports ( + id serial PRIMARY KEY, + code text NOT NULL UNIQUE, -- code OACI (LFPG, LFPO, ...) + nom text NOT NULL, + lat double precision NOT NULL, + lon double precision NOT NULL, + rayon_km double precision NOT NULL DEFAULT 8, + type text NOT NULL DEFAULT 'aerodrome', -- aeroport|aerodrome|heliport|base + actif boolean NOT NULL DEFAULT true, + commentaire text +); + +COMMENT ON TABLE adsb.airports IS + 'Aérodromes connus, utilisés comme zones d''exclusion pour la détection des ' + 'départs atypiques. actif=false désactive l''exclusion sans perdre la fiche.'; +COMMENT ON COLUMN adsb.airports.rayon_km IS + 'Rayon d''exclusion en km : zone dans laquelle une montée initiale est ' + 'considérée comme issue de ce terrain.'; + +-- Colonne géographique générée, pour un ST_DWithin indexable +ALTER TABLE adsb.airports + ADD COLUMN IF NOT EXISTS geom geography(Point,4326) + GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography) STORED; + +CREATE INDEX IF NOT EXISTS airports_geom_gix ON adsb.airports USING GIST (geom); +CREATE INDEX IF NOT EXISTS airports_actif_idx ON adsb.airports (actif) WHERE actif; + +-- ── Données de référence ───────────────────────────────────────────────────── +-- Rayons de départ, à ajuster selon les résultats observés. +INSERT INTO adsb.airports (code, nom, lat, lon, rayon_km, type, commentaire) VALUES + ('LFPG', 'Paris-Charles de Gaulle', 49.0097, 2.5479, 15, 'aeroport', + 'Doublets de pistes, départs très étalés — rayon large'), + ('LFPO', 'Paris-Orly', 48.7233, 2.3794, 12, 'aeroport', + 'Départs vers le sud et l''ouest'), + ('LFPB', 'Paris-Le Bourget', 48.9694, 2.4414, 10, 'aeroport', + 'Aviation d''affaires, trafic dense en jets'), + ('LFPV', 'Vélizy-Villacoublay', 48.7744, 2.2014, 8, 'base', + 'Base aérienne 107 — à ~6 km de la station, très bien captée'), + ('LFPN', 'Toussus-le-Noble', 48.7519, 2.1062, 8, 'aerodrome', + 'Aviation légère et écoles — fort volume de tours de piste'), + ('LFPZ', 'Saint-Cyr-l''École', 48.8081, 2.0736, 6, 'aerodrome', + 'Aviation légère, planeurs'), + ('LFPI', 'Paris-Issy-les-Moulineaux', 48.8331, 2.2725, 4, 'heliport', + 'Héliport de Paris — à ~1 km de la station, couverture excellente'), + ('LFPM', 'Melun-Villaroche', 48.6047, 2.6711, 8, 'aerodrome', + 'Essais en vol et aviation générale'), + ('LFPT', 'Pontoise-Cormeilles', 49.0966, 2.0408, 8, 'aerodrome', + 'Aviation légère, école'), + ('LFPL', 'Lognes-Émerainville', 48.8228, 2.6261, 6, 'aerodrome', + 'Aviation légère'), + ('LFPK', 'Coulommiers-Voisins', 48.8375, 3.0161, 8, 'aerodrome', + 'Limite est de la couverture'), + ('LFXU', 'Les Mureaux', 48.9983, 1.9433, 6, 'aerodrome', + 'Aérodrome industriel'), + ('LFPU', 'Moret-Épisy', 48.3422, 2.7986, 6, 'aerodrome', + 'Planeurs — souvent hors de portée'), + ('LFAI', 'Nangis-Les Loges', 48.5961, 3.0067, 6, 'aerodrome', + 'Aviation légère, parachutisme'), + ('LFOP', 'Rouen-Vallée de Seine', 49.3842, 1.1758, 10, 'aeroport', + 'Hors couverture nominale — désactivable'), + ('LFOB', 'Beauvais-Tillé', 49.4544, 2.1128, 12, 'aeroport', + 'Limite nord de la couverture') +ON CONFLICT (code) DO NOTHING; + +-- Sites au-delà de la portée utile : renseignés mais désactivés par défaut, +-- pour éviter d'exclure inutilement des zones où l'on ne capte rien. +UPDATE adsb.airports SET actif = false WHERE code IN ('LFOP', 'LFPU'); + +SELECT code, nom, rayon_km, type, actif FROM adsb.airports ORDER BY actif DESC, code; diff --git a/sql/05_airports_zones.sql b/sql/05_airports_zones.sql new file mode 100644 index 0000000..ff36b75 --- /dev/null +++ b/sql/05_airports_zones.sql @@ -0,0 +1,76 @@ +-- ============================================================================= +-- 05_airports_zones.sql — gestion manuelle des zones d'exclusion +-- +-- Étend adsb.airports pour accueillir, à côté des aérodromes de référence, des +-- zones ajoutées à la main depuis l'interface (typiquement une zone découverte +-- par l'analyse et que l'on souhaite écarter des recherches suivantes). +-- +-- Idempotent : peut être relancé sans risque. +-- +-- Usage : +-- docker exec -i wordpress-postgres psql -U pgz_admin -d wpz_postgres \ +-- -v ON_ERROR_STOP=1 < 05_airports_zones.sql +-- ============================================================================= + +\set ON_ERROR_STOP on + +-- Origine de la fiche : distingue ce qui vient du référentiel de ce que +-- l'utilisateur a ajouté. Permet de réinitialiser les aérodromes sans perdre +-- les zones personnelles, et inversement. +ALTER TABLE adsb.airports + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'reference'; + +ALTER TABLE adsb.airports + ADD COLUMN IF NOT EXISTS cree_le timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE adsb.airports + ADD COLUMN IF NOT EXISTS modifie_le timestamptz NOT NULL DEFAULT now(); + +-- Contrainte de valeurs, ajoutée seulement si absente +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'airports_source_chk') THEN + ALTER TABLE adsb.airports + ADD CONSTRAINT airports_source_chk + CHECK (source IN ('reference', 'manuelle')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'airports_rayon_chk') THEN + ALTER TABLE adsb.airports + ADD CONSTRAINT airports_rayon_chk + CHECK (rayon_km > 0 AND rayon_km <= 100); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'airports_coord_chk') THEN + ALTER TABLE adsb.airports + ADD CONSTRAINT airports_coord_chk + CHECK (lat BETWEEN -90 AND 90 AND lon BETWEEN -180 AND 180); + END IF; +END $$; + +COMMENT ON COLUMN adsb.airports.source IS + 'reference = aérodrome du référentiel livré ; manuelle = zone ajoutée depuis ' + 'l''interface (zone découverte, terrain privé, hélisurface…)'; + +-- Le type accepte désormais des valeurs propres aux zones manuelles. +-- Pas de contrainte CHECK sur `type` : la liste doit rester ouverte, on ne sait +-- pas d'avance ce que l'utilisateur va découvrir. +COMMENT ON COLUMN adsb.airports.type IS + 'aeroport | aerodrome | heliport | base | ulm | helisurface | prive | inconnu | …'; + +-- Mise à jour automatique de modifie_le +CREATE OR REPLACE FUNCTION adsb.airports_touch() RETURNS trigger AS $$ +BEGIN + NEW.modifie_le := now(); + RETURN NEW; +END $$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS airports_touch_trg ON adsb.airports; +CREATE TRIGGER airports_touch_trg + BEFORE UPDATE ON adsb.airports + FOR EACH ROW EXECUTE FUNCTION adsb.airports_touch(); + +-- Les fiches existantes viennent du référentiel +UPDATE adsb.airports SET source = 'reference' WHERE source IS NULL; + +SELECT source, count(*) FILTER (WHERE actif) AS actives, + count(*) AS total +FROM adsb.airports GROUP BY source ORDER BY source; diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..23400ab --- /dev/null +++ b/start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Trouve le device du dongle ADS-B par numéro de série et lance docker compose + +DONGLE=$(lsusb | grep "0bda:2838") +if [ -z "$DONGLE" ]; then + echo "ERREUR: Dongle ADS-B non trouvé !" + exit 1 +fi + +# Extraire bus et device +BUS=$(echo "$DONGLE" | awk '{print $2}') +DEV=$(echo "$DONGLE" | awk '{print $4}' | tr -d ':') +DEVICE_PATH="/dev/bus/usb/$(printf '%03d' $BUS)/$(printf '%03d' $DEV)" + +echo "Dongle trouvé : $DEVICE_PATH" + +# Mettre à jour le docker-compose.yml dynamiquement +sed -i "s|/dev/bus/usb/[0-9]*/[0-9]*:/dev/adsb_dongle|${DEVICE_PATH}:/dev/adsb_dongle|" /data/adsb/docker-compose.yml + +docker compose -f /data/adsb/docker-compose.yml up -d diff --git a/update-adsb.sh b/update-adsb.sh new file mode 100755 index 0000000..9f7ac23 --- /dev/null +++ b/update-adsb.sh @@ -0,0 +1,518 @@ +#!/usr/bin/env bash +# ============================================================================= +# update-adsb.sh — Mise à jour sécurisée du stack ADS-B +# +# Stratégie : +# - Images publiques : docker pull + recréation si nouvelle version +# - Images locales : rebuild --no-cache (nouvelles versions des paquets) +# - Données/config : jamais touchées (volumes nommés + fichiers sources) +# - Rollback : automatique si un conteneur ne démarre pas +# +# Usage : +# sudo ./update-adsb.sh [--check-only] [--service ] [--force] +# ============================================================================= + +set -euo pipefail + +STACK_DIR="${STACK_DIR:-/data/adsb}" +LOG_FILE="/var/log/adsb-update.log" +BACKUP_DIR="/data/adsb/backups" + +# ── Fichiers d'environnement (v4.2) ────────────────────────────────────────── +# Depuis le passage aux profiles Compose, docker compose doit recevoir DEUX +# --env-file : .env (secret PG_PASSWORD) et .env.receivers (RX_SERIAL + +# COMPOSE_PROFILES, généré par gen_env.sh). Sans eux, Compose émet +# « The "RX1_SERIAL" variable is not set » et recréerait readsb avec un numéro +# de série vide — le conteneur démarrerait alors sur un dongle arbitraire. +ENV_FILE="${STACK_DIR}/.env" +ENV_RECEIVERS="${STACK_DIR}/.env.receivers" +GEN_ENV="${STACK_DIR}/scripts/gen_env.sh" + +COMPOSE_ARGS=(-f "${STACK_DIR}/docker-compose.yml") +[[ -f "$ENV_FILE" ]] && COMPOSE_ARGS+=(--env-file "$ENV_FILE") +[[ -f "$ENV_RECEIVERS" ]] && COMPOSE_ARGS+=(--env-file "$ENV_RECEIVERS") +COMPOSE="docker compose ${COMPOSE_ARGS[*]}" + +# ── Couleurs ───────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; } +success() { echo -e "${GREEN}[OK]${NC} $*" | tee -a "$LOG_FILE"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; } +error() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "$LOG_FILE" >&2; } +header() { echo -e "\n${BOLD}${BLUE}=== $* ===${NC}\n" | tee -a "$LOG_FILE"; } +die() { error "$*"; exit 1; } + +# ── Arguments ──────────────────────────────────────────────────────────────── +CHECK_ONLY=false +FORCE=false +TARGET_SERVICE="" + +while [[ $# -gt 0 ]]; do + case $1 in + --check-only) CHECK_ONLY=true ; shift ;; + --force) FORCE=true ; shift ;; + --service) TARGET_SERVICE="$2" ; shift 2 ;; + -h|--help) + echo "Usage: $0 [--check-only] [--service ] [--force]" + echo " --check-only Vérifie les mises à jour sans les appliquer" + echo " --service nom Met à jour uniquement ce service" + echo " --force Force le rebuild même sans nouvelle version" + exit 0 ;; + *) die "Argument inconnu : $1" ;; + esac +done + +# ── Prérequis ──────────────────────────────────────────────────────────────── +[[ $EUID -eq 0 ]] || die "Exécuter en root" +command -v docker &>/dev/null || die "Docker non trouvé" +[[ -f "${STACK_DIR}/docker-compose.yml" ]] || die "docker-compose.yml introuvable dans ${STACK_DIR}" + +# ── Prérequis v4.2 : environnement multi-récepteurs ────────────────────────── +[[ -f "$ENV_FILE" ]] || warn "$ENV_FILE absent — PG_PASSWORD ne sera pas injecté" + +if [[ -x "$GEN_ENV" ]]; then + # Régénère .env.receivers depuis les dongles réellement branchés : évite de + # recréer un conteneur sur un slot dont le dongle a disparu depuis le + # dernier démarrage du stack. + if "$GEN_ENV" >/dev/null 2>&1; then + RX_COUNT=$(grep -c '^RX[0-9]*_SERIAL=..*' "$ENV_RECEIVERS" 2>/dev/null || echo 0) + PROFILES=$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_RECEIVERS" 2>/dev/null) + success "Environnement régénéré : ${RX_COUNT} récepteur(s) — profiles=${PROFILES:-aucun}" + [[ -z "$PROFILES" ]] && warn "Aucun dongle RTL-SDR détecté — les services readsb/tar1090/adsb2pg ne seront pas recréés" + else + warn "gen_env.sh a échoué — utilisation de $ENV_RECEIVERS tel quel" + fi +else + warn "$GEN_ENV absent — stack antérieur à la v4.2 ? Les profiles ne seront pas recalculés" +fi + +mkdir -p "$BACKUP_DIR" +echo "" >> "$LOG_FILE" +echo "═══════════════════════════════════════════════════" >> "$LOG_FILE" +echo " Mise à jour ADS-B — $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" +echo "═══════════════════════════════════════════════════" >> "$LOG_FILE" + +# ── Définition des services ─────────────────────────────────────────────────── +# Format : "nom:type:image_ou_contexte" +# type = public (image Docker Hub/ghcr) | local (build local) +declare -A SERVICE_TYPE=( + [readsb]="public" + [tar1090]="public" + [adsb2pg]="local" + [webapp]="local" + # v4.2 : slots 2 et 3, présents seulement si un dongle les alimente + [readsb2]="public" + [tar10902]="public" + [adsb2pg2]="local" + [readsb3]="public" + [tar10903]="public" + [adsb2pg3]="local" +) + +declare -A SERVICE_IMAGE=( + [readsb]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" + [tar1090]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" + [adsb2pg]="" + [webapp]="" + [readsb2]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" + [tar10902]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" + [adsb2pg2]="" + [readsb3]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" + [tar10903]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" + [adsb2pg3]="" +) + +# Nom du conteneur associé à un service (les slots 2/3 ne suivent pas la +# convention adsb- : le service tar10902 donne adsb-tar10902). +service_container() { + case "$1" in + tar10902) echo "adsb-tar10902" ;; + tar10903) echo "adsb-tar10903" ;; + *) echo "adsb-$1" ;; + esac +} + +# Services réellement déployables : on ne traite un slot que si son conteneur +# TOURNE. docker inspect réussit aussi sur un conteneur arrêté — un slot dont +# le dongle a été retiré laisse un conteneur stoppé derrière lui, qu'il ne faut +# ni mettre à jour ni redémarrer (il serait relancé sans profile actif). +service_is_active() { + [[ "$(docker inspect --format '{{.State.Running}}' \ + "$(service_container "$1")" 2>/dev/null)" == "true" ]] +} + +# Services à traiter +if [[ -n "$TARGET_SERVICE" ]]; then + SERVICES=("$TARGET_SERVICE") +else + # webapp d'abord (toujours présent), puis les slots réellement déployés + SERVICES=("webapp") + for _svc in readsb tar1090 adsb2pg readsb2 tar10902 adsb2pg2 readsb3 tar10903 adsb2pg3; do + service_is_active "$_svc" && SERVICES+=("$_svc") + done + info "Services déployés à traiter : ${SERVICES[*]}" +fi + +# ── Sauvegarde de la config avant mise à jour ───────────────────────────────── +backup_config() { + header "Sauvegarde de la configuration" + local backup="${BACKUP_DIR}/config_$(date +%Y%m%d_%H%M%S)" + mkdir -p "$backup" + + # docker-compose.yml + cp "${STACK_DIR}/docker-compose.yml" "$backup/" + + # Config adsb2pg + [[ -f "${STACK_DIR}/adsb2pg/adsb2pg.py" ]] && \ + cp "${STACK_DIR}/adsb2pg/adsb2pg.py" "$backup/" + + # Config webapp + [[ -d "${STACK_DIR}/webapp/src" ]] && \ + cp -r "${STACK_DIR}/webapp/src" "$backup/webapp-src/" + + # adsb.conf + [[ -f "${STACK_DIR}/adsb.conf" ]] && \ + cp "${STACK_DIR}/adsb.conf" "$backup/" + + success "Config sauvegardée dans $backup" + echo "$backup" +} + +# ── Sauvegarder l'ID de l'image actuelle (pour rollback) ───────────────────── +get_image_id() { + local service=$1 + docker inspect --format='{{.Image}}' "$(service_container "$service")" 2>/dev/null || echo "" +} + +# ── Vérifier si une nouvelle image est disponible ──────────────────────────── +check_public_update() { + local service=$1 + local image="${SERVICE_IMAGE[$service]}" + local current_id + current_id=$(get_image_id "$service") + + info "Vérification de $service ($image)..." + + # Puller la nouvelle image + if ! docker pull "$image" 2>&1 | tee -a "$LOG_FILE"; then + warn "Impossible de puller $image" + return 1 + fi + + local new_id + new_id=$(docker inspect --format='{{.Id}}' "$image" 2>/dev/null || echo "") + + if [[ "$current_id" == "$new_id" ]] && [[ "$FORCE" != "true" ]]; then + success "$service : déjà à jour (${new_id:7:12})" + return 1 # Pas de mise à jour nécessaire + fi + + if [[ "$current_id" != "$new_id" ]]; then + info "$service : nouvelle version disponible" + info " Avant : ${current_id:7:12}" + info " Après : ${new_id:7:12}" + else + info "$service : forçage du redémarrage" + fi + return 0 # Mise à jour disponible/forcée +} + +# ── Vérifier les mises à jour des paquets dans une image locale ─────────────── +check_local_update() { + local service=$1 + info "Vérification des mises à jour de sécurité pour $service..." + + # Simuler un build avec --no-cache pour voir s'il y a des paquets à mettre à jour + # On vérifie la date du dernier build + local built_at + built_at=$(docker inspect --format='{{.Created}}' "$(service_container "$service")" 2>/dev/null || echo "") + + if [[ -z "$built_at" ]]; then + warn "$service : conteneur non trouvé, build nécessaire" + return 0 + fi + + local built_epoch + built_epoch=$(date -d "$built_at" +%s 2>/dev/null || echo 0) + local now_epoch + now_epoch=$(date +%s) + local age_days=$(( (now_epoch - built_epoch) / 86400 )) + + if [[ $age_days -ge 7 ]] || [[ "$FORCE" == "true" ]]; then + info "$service : image vieille de ${age_days} jours → rebuild recommandé" + return 0 + else + success "$service : image récente (${age_days} jours) — pas de rebuild nécessaire" + return 1 + fi +} + +# ── Mettre à jour un service public ────────────────────────────────────────── +update_public_service() { + local service=$1 + local old_image_id + old_image_id=$(get_image_id "$service") + + info "Mise à jour de $service..." + + # Recréer le conteneur avec la nouvelle image + if ! $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE"; then + error "Échec du redémarrage de $service" + return 1 + fi + + # Vérifier que le conteneur est sain après 15s + sleep 15 + if verify_service "$service"; then + success "$service mis à jour avec succès" + # Supprimer l'ancienne image si elle n'est plus utilisée + docker image prune -f 2>/dev/null || true + return 0 + else + error "$service ne répond pas après mise à jour — rollback" + rollback_service "$service" "$old_image_id" + return 1 + fi +} + +# ── Rebuilder un service local ──────────────────────────────────────────────── +update_local_service() { + local service=$1 + + info "Rebuild de $service (--no-cache pour forcer MAJ des paquets)..." + + # Arrêter proprement + $COMPOSE stop "$service" 2>&1 | tee -a "$LOG_FILE" || true + + # Rebuild sans cache + if ! $COMPOSE build --no-cache "$service" 2>&1 | tee -a "$LOG_FILE"; then + error "Échec du build de $service" + # Relancer avec l'ancienne image si elle existe encore + $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE" || true + return 1 + fi + + # Relancer + if ! $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE"; then + error "Échec du démarrage de $service après rebuild" + return 1 + fi + + # Vérifier + sleep 20 + if verify_service "$service"; then + success "$service rebuild et redémarré avec succès" + # Nettoyer les anciennes images + docker image prune -f 2>/dev/null || true + return 0 + else + error "$service ne répond pas après rebuild" + return 1 + fi +} + +# ── Vérification qu'un service est opérationnel ─────────────────────────────── +verify_service() { + local service=$1 + local container; container=$(service_container "$service") + local max_attempts=6 + local attempt=0 + + while [[ $attempt -lt $max_attempts ]]; do + local status + status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "") + + case "$status" in + running) + # Vérification supplémentaire selon le service + case "$service" in + readsb) + # Vérifie que readsb décode des messages + if docker logs "$container" 2>&1 | \ + grep -q "RTL\|tuner\|Found"; then + return 0 + fi + ;; + tar1090) + # Vérifie que tar1090 a une connexion Beast + if docker logs "$container" 2>&1 | \ + grep -q "Connection established\|successfully started"; then + return 0 + fi + ;; + adsb2pg) + # Vérifie la connexion Postgres + if docker logs "$container" 2>&1 | \ + grep -q "Connecté\|initialisé"; then + return 0 + fi + ;; + webapp) + # Vérifie qu'Apache répond + if curl -s --max-time 3 \ + "http://127.0.0.1:8080/" \ + -o /dev/null -w "%{http_code}" 2>/dev/null \ + | grep -q "200\|302"; then + return 0 + fi + ;; + esac + ;; + exited|dead) + error "$container est arrêté (status: $status)" + docker logs "$container" 2>&1 | tail -10 | tee -a "$LOG_FILE" + return 1 + ;; + esac + + ((attempt++)) + [[ $attempt -lt $max_attempts ]] && sleep 5 + done + + warn "$service : timeout de vérification (peut encore démarrer)" + return 0 # On ne rollback pas sur timeout +} + +# ── Rollback vers une ancienne image ───────────────────────────────────────── +rollback_service() { + local service=$1 + local old_image_id=$2 + + warn "Rollback de $service vers $old_image_id..." + + if [[ -z "$old_image_id" ]]; then + error "Pas d'image précédente pour rollback" + return 1 + fi + + # Forcer l'utilisation de l'ancienne image + docker tag "$old_image_id" "$(service_container "$service"):rollback" 2>/dev/null || true + docker inspect "$(service_container "$service"):rollback" &>/dev/null && \ + $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE" + + if verify_service "$service"; then + success "Rollback de $service réussi" + else + error "Rollback échoué — intervention manuelle nécessaire" + fi +} + +# ── Nettoyer les images orphelines ──────────────────────────────────────────── +cleanup_images() { + header "Nettoyage des images obsolètes" + local freed + freed=$(docker image prune -f 2>/dev/null | grep "reclaimed" || echo "") + [[ -n "$freed" ]] && info "Espace libéré : $freed" || info "Rien à nettoyer" +} + +# ── Afficher le statut final ────────────────────────────────────────────────── +show_status() { + header "Statut du stack après mise à jour" + $COMPOSE ps 2>&1 | tee -a "$LOG_FILE" + + echo "" + info "Vérification du flux ADS-B :" + local count + count=$(curl -s --max-time 3 "http://127.0.0.1:8090/data/aircraft.json" \ + 2>/dev/null | python3 -c " +import json,sys +try: + d=json.load(sys.stdin) + ac=[a for a in d.get('aircraft',[]) if a.get('lat')] + print(f' Messages={d[\"messages\"]} Avions={len(ac)}') +except: + print(' tar1090 non disponible') +" 2>/dev/null || echo " tar1090 non disponible") + info "$count" +} + +# ── Mode check-only ─────────────────────────────────────────────────────────── +if [[ "$CHECK_ONLY" == "true" ]]; then + header "Vérification des mises à jour disponibles (mode lecture seule)" + for service in "${SERVICES[@]}"; do + type="${SERVICE_TYPE[$service]}" + case "$type" in + public) check_public_update "$service" && \ + echo " → Mise à jour disponible pour $service" || true ;; + local) check_local_update "$service" && \ + echo " → Rebuild recommandé pour $service" || true ;; + esac + done + echo "" + info "Lancez sans --check-only pour appliquer les mises à jour" + exit 0 +fi + +# ── Mise à jour principale ──────────────────────────────────────────────────── +header "Mise à jour du stack ADS-B — $(date '+%Y-%m-%d %H:%M:%S')" + +# 1. Sauvegarder la config +BACKUP_PATH=$(backup_config) + +# 2. Traiter chaque service +UPDATED=() +FAILED=() +SKIPPED=() + +for service in "${SERVICES[@]}"; do + echo "" + type="${SERVICE_TYPE[$service]:-}" + [[ -z "$type" ]] && { warn "Service inconnu : $service — ignoré"; continue; } + + case "$type" in + public) + if check_public_update "$service"; then + if [[ "$CHECK_ONLY" != "true" ]]; then + if update_public_service "$service"; then + UPDATED+=("$service") + else + FAILED+=("$service") + fi + fi + else + SKIPPED+=("$service") + fi + ;; + local) + if check_local_update "$service"; then + if [[ "$CHECK_ONLY" != "true" ]]; then + if update_local_service "$service"; then + UPDATED+=("$service") + else + FAILED+=("$service") + fi + fi + else + SKIPPED+=("$service") + fi + ;; + esac +done + +# 3. Nettoyage +cleanup_images + +# 4. Statut final +show_status + +# 5. Résumé +header "Résumé" +[[ ${#UPDATED[@]} -gt 0 ]] && success "Mis à jour : ${UPDATED[*]}" +[[ ${#SKIPPED[@]} -gt 0 ]] && info "Pas de MAJ : ${SKIPPED[*]}" +[[ ${#FAILED[@]} -gt 0 ]] && error "Échecs : ${FAILED[*]}" + +info "Log complet : $LOG_FILE" +info "Backup config : $BACKUP_PATH" + +# 6. Rotation des backups (garder les 10 derniers) +ls -dt "${BACKUP_DIR}"/config_* 2>/dev/null | tail -n +11 | xargs rm -rf 2>/dev/null || true + +# 7. Rotation des logs (garder 1 Mo max) +if [[ -f "$LOG_FILE" ]] && [[ $(stat -c%s "$LOG_FILE") -gt 1048576 ]]; then + mv "$LOG_FILE" "${LOG_FILE}.old" + info "Log archivé (${LOG_FILE}.old)" +fi + +[[ ${#FAILED[@]} -gt 0 ]] && exit 1 || exit 0 diff --git a/update-webapp.sh b/update-webapp.sh new file mode 100755 index 0000000..3658b16 --- /dev/null +++ b/update-webapp.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# update-webapp.sh — Rebuild et redéploiement de la webapp ADS-B +# Usage : ./update-webapp.sh [--no-cache] [--check-only] +# ═══════════════════════════════════════════════════════════════════════════════ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Fichiers d'environnement (v4.2) ────────────────────────────────────────── +# Depuis les profiles Compose, un « docker compose » nu affiche +# « The "RX1_SERIAL" variable is not set » à chaque appel. Inoffensif pour la +# webapp (aucun profile), mais le bruit masque les vraies erreurs — et si +# PG_PASSWORD n'est pas injecté, le conteneur démarre sans accès à la base. +COMPOSE_ARGS=() +[[ -f "$SCRIPT_DIR/.env" ]] && COMPOSE_ARGS+=(--env-file "$SCRIPT_DIR/.env") +[[ -f "$SCRIPT_DIR/.env.receivers" ]] && COMPOSE_ARGS+=(--env-file "$SCRIPT_DIR/.env.receivers") + +dc() { docker compose "${COMPOSE_ARGS[@]}" "$@"; } + +# ── Couleurs ────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +ok() { echo -e " ${GREEN}✓${NC} $1"; } +fail() { echo -e " ${RED}✗${NC} $1"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1"; } +info() { echo -e " ${CYAN}→${NC} $1"; } +hdr() { echo -e "\n${BOLD}$1${NC}"; } + +NO_CACHE="" +CHECK_ONLY=0 +for arg in "$@"; do + case "$arg" in + --no-cache) NO_CACHE="--no-cache" ;; + --check-only) CHECK_ONLY=1 ;; + esac +done + +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Déploiement webapp ADS-B Linux-25 ║${NC}" +echo -e "${BOLD}║ $(date '+%a %d/%m/%Y %H:%M:%S') ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + +# ── 1. Prérequis ────────────────────────────────────────────────────────────── +hdr "1. Vérification des prérequis" + +# Docker disponible ? +if ! docker info &>/dev/null; then + fail "Docker non disponible — arrêt" + exit 1 +fi +ok "Docker disponible ($(docker version --format '{{.Server.Version}}' 2>/dev/null))" + +# Dockerfile présent ? +if [ ! -f "$SCRIPT_DIR/webapp/Dockerfile" ]; then + fail "webapp/Dockerfile introuvable dans $SCRIPT_DIR" + exit 1 +fi +ok "Dockerfile présent : $SCRIPT_DIR/webapp/Dockerfile" + +# Sources présentes ? +SRC_COUNT=$(find "$SCRIPT_DIR/webapp/src" -type f 2>/dev/null | wc -l) +if [ "$SRC_COUNT" -eq 0 ]; then + fail "Aucun fichier source dans webapp/src/" + exit 1 +fi +ok "$SRC_COUNT fichiers sources dans webapp/src/" + +# ── 2. Accès internet ───────────────────────────────────────────────────────── +hdr "2. Vérification accès internet" + +INTERNET=0 +URLS_TO_CHECK=( + "https://cdn.jsdelivr.net/" + "https://unpkg.com/" +) + +for url in "${URLS_TO_CHECK[@]}"; do + CODE=$(curl -s --max-time 5 -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000") + if [ "$CODE" != "000" ] && [ "$CODE" != "599" ]; then + ok "Accès internet OK ($url → HTTP $CODE)" + INTERNET=1 + break + else + warn "Pas de réponse : $url" + fi +done + +if [ "$INTERNET" -eq 0 ]; then + warn "Aucun accès internet détecté" + # Vérifier si les assets sont déjà dans l'image existante + EXISTING_ASSETS=$(docker exec adsb-webapp ls /var/www/html/assets/ 2>/dev/null | wc -l || echo 0) + if [ "$EXISTING_ASSETS" -gt 0 ]; then + warn "Assets déjà présents dans le conteneur ($EXISTING_ASSETS fichiers)" + warn "Le build va échouer si l'image doit être recréée depuis zéro" + warn "Option : copier uniquement les sources sans rebuild" + echo "" + echo -e " ${YELLOW}Que voulez-vous faire ?${NC}" + echo " [1] Tenter le rebuild quand même (peut échouer sans internet)" + echo " [2] Copier uniquement les sources dans le conteneur existant" + echo " [3] Annuler" + read -rp " Choix [1/2/3] : " CHOIX + case "$CHOIX" in + 2) + hdr "Copie des sources dans le conteneur existant" + for f in "$SCRIPT_DIR/webapp/src/"*; do + fname=$(basename "$f") + if [ -d "$f" ]; then + docker cp "$f" "adsb-webapp:/var/www/html/$fname" && \ + ok "Copié : $fname/" || warn "Échec : $fname/" + else + docker cp "$f" "adsb-webapp:/var/www/html/$fname" && \ + ok "Copié : $fname" || warn "Échec : $fname" + fi + done + docker exec adsb-webapp chown -R www-data:www-data /var/www/html/ 2>/dev/null || true + ok "Sources mises à jour sans rebuild" + exit 0 + ;; + 3) + info "Annulé" + exit 0 + ;; + *) + warn "Tentative de rebuild sans garantie de succès" + ;; + esac + else + fail "Pas d'internet et aucun conteneur existant — impossible de builder" + exit 1 + fi +fi + +[ "$CHECK_ONLY" -eq 1 ] && { info "Mode --check-only : arrêt ici"; exit 0; } + +# ── 3. Build de l'image ─────────────────────────────────────────────────────── +hdr "3. Build de l'image webapp" + +info "Lancement du build${NO_CACHE:+ (--no-cache)}..." +BUILD_START=$(date +%s) + +if dc build $NO_CACHE webapp 2>&1 | tee /tmp/webapp-build.log | \ + grep -E "^(Step|#[0-9]|Successfully|ERROR|error)" ; then + BUILD_END=$(date +%s) + ok "Build réussi en $((BUILD_END - BUILD_START))s" +else + BUILD_END=$(date +%s) + fail "Build échoué après $((BUILD_END - BUILD_START))s" + echo "" + warn "Dernières lignes du log :" + tail -20 /tmp/webapp-build.log + exit 1 +fi + +# ── 4. Redémarrage du conteneur ─────────────────────────────────────────────── +hdr "4. Redémarrage du conteneur" + +info "Arrêt de adsb-webapp..." +dc stop webapp +dc rm -f webapp +info "Démarrage avec la nouvelle image..." +dc up -d webapp +sleep 8 + +# ── 5. Vérification post-déploiement ───────────────────────────────────────── +hdr "5. Vérification post-déploiement" + +# Conteneur running ? +STATUS=$(docker inspect --format '{{.State.Status}}' adsb-webapp 2>/dev/null || echo "absent") +[ "$STATUS" = "running" ] && ok "Conteneur adsb-webapp : running" \ + || fail "Conteneur adsb-webapp : $STATUS" + +# Port 8080 en écoute ? +ss -tlnp 2>/dev/null | grep -q ":8080" \ + && ok "Port 8080 en écoute" \ + || fail "Port 8080 absent" + +# HTTP 200 ? +HTTP=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:8080/" 2>/dev/null) +[ "$HTTP" = "200" ] && ok "HTTP 200 sur http://127.0.0.1:8080/" \ + || fail "HTTP $HTTP sur http://127.0.0.1:8080/" + +# Nombre d'onglets ? +TABS=$(docker exec adsb-webapp grep -c 'href="#tab' /var/www/html/index.php 2>/dev/null || echo 0) +[ "$TABS" -ge 5 ] && ok "$TABS onglets détectés dans index.php" \ + || warn "$TABS onglets détectés (< 5 attendus)" + +# Taille app.js ? +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" + +# ── Contrôles fonctionnels de l'API (v4.2) ─────────────────────────────────── +# Le fuseau doit être ancré : sans date_default_timezone_set, PHP retombe sur +# UTC et la carte historique interroge une fenêtre décalée de 2 h — elle +# revient alors systématiquement vide, sans la moindre erreur HTTP. +if docker exec adsb-webapp grep -q "date_default_timezone_set" /var/www/html/api.php 2>/dev/null; then + ok "api.php : fuseau horaire ancré (date_default_timezone_set)" +else + warn "api.php : date_default_timezone_set absent — carte historique probablement vide (décalage UTC/CEST)" +fi + +for act in live kpi trajectories; do + BODY=$(curl -s --max-time 10 "http://127.0.0.1:8080/api.php?action=${act}" 2>/dev/null) + if echo "$BODY" | head -c 1 | grep -q '{'; then + ok "api.php?action=${act} : JSON valide" + else + warn "api.php?action=${act} : réponse inattendue" + fi +done + +# La carte historique est le seul endpoint sensible au fuseau : on l'interroge +# sur une fenêtre récente, qui doit contenir des données si le stack reçoit. +HIST_FROM=$(date -d '-40 minutes' '+%Y-%m-%d %H:%M:%S') +HIST=$(curl -s --max-time 20 --get \ + --data-urlencode "action=trajectories_history" \ + --data-urlencode "from=${HIST_FROM}" \ + --data-urlencode "window=30" \ + "http://127.0.0.1:8080/api.php" 2>/dev/null) +HIST_COUNT=$(echo "$HIST" | grep -oP '"count"\s*:\s*\K[0-9]+' | head -1) +if [ "${HIST_COUNT:-0}" -gt 0 ]; then + ok "Carte historique : ${HIST_COUNT} avion(s) sur les 30 dernières minutes" +else + warn "Carte historique : 0 avion sur une fenêtre récente — vérifier le fuseau (api.php) ou l'ingestion adsb2pg" +fi + +# Assets présents ? +ASSETS=$(docker exec adsb-webapp ls /var/www/html/assets/ 2>/dev/null | wc -l) +ok "$ASSETS fichiers assets dans /var/www/html/assets/" + +# Drapeaux : sans eux, tous les pays s'affichent avec le pictogramme « ? ». +# Le Dockerfile les télécharge, mais un build sans réseau les laisse absents. +FLAG_N=$(docker exec adsb-webapp sh -c 'ls /var/www/html/assets/flags/*.png 2>/dev/null | wc -l' 2>/dev/null) +if [ "${FLAG_N:-0}" -gt 50 ]; then + ok "$FLAG_N drapeau(x) disponible(s)" +else + warn "Seulement ${FLAG_N:-0} drapeau(x) — les pays afficheront un pictogramme générique" + warn "Correctif : /data/adsb/scripts/fetch_flags.sh puis relancer ce script" +fi + +# Endpoints d'analyse des départs +for act in airports departures_offfield_zones; do + BODY=$(curl -s --max-time 25 "http://127.0.0.1:8080/api.php?action=${act}&window=1440" 2>/dev/null) + if echo "$BODY" | grep -q '"error"'; then + warn "api.php?action=${act} : $(echo "$BODY" | head -c 110)" + elif echo "$BODY" | head -c 1 | grep -q '{'; then + ok "api.php?action=${act} : JSON valide" + else + warn "api.php?action=${act} : réponse inattendue" + fi +done + +# IP pour le lien final +IP=$(ip -4 addr show 2>/dev/null | grep -oP '(?<=inet )192\.[0-9.]+' | head -1) +[ -z "$IP" ] && IP=$(hostname -I | awk '{print $1}') + +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ ✓ Déploiement terminé ║${NC}" +echo -e "${BOLD}║ Dashboard : http://${IP}:8080${NC}${BOLD} ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" diff --git a/watchlist_schema.sql b/watchlist_schema.sql new file mode 100644 index 0000000..054f387 --- /dev/null +++ b/watchlist_schema.sql @@ -0,0 +1,71 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- Listes de surveillance d'aéronefs +-- +-- Deux tables plutôt qu'une colonne « liste » libre : une liste est un objet +-- qu'on renomme, colore et supprime, et sa suppression doit emporter ses +-- membres. Une chaîne répétée sur chaque ligne rendrait le renommage fragile. +-- +-- La clé de surveillance est l'adresse ICAO 24 bits, stable pour une cellule +-- donnée, et non l'indicatif, qui change à chaque vol et manque souvent. +-- +-- À jouer avec ON_ERROR_STOP=1. +-- ═══════════════════════════════════════════════════════════════════════════ + +BEGIN; + +CREATE TABLE IF NOT EXISTS adsb.watchlists ( + id serial PRIMARY KEY, + nom varchar(60) NOT NULL UNIQUE, + couleur varchar(9) NOT NULL DEFAULT '#d29922', + description text, + actif boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE adsb.watchlists IS + 'Listes de surveillance nommées (ex. Militaires, Hélicoptères).'; + +CREATE TABLE IF NOT EXISTS adsb.watchlist_items ( + id serial PRIMARY KEY, + watchlist_id integer NOT NULL + REFERENCES adsb.watchlists(id) ON DELETE CASCADE, + icao varchar(6) NOT NULL, + motif text, + -- Instantané des métadonnées au moment de la mise sous surveillance. + -- Redondant avec aircraft_history, et c'est voulu : il garde une trace de + -- ce qui avait motivé l'ajout, même si la fiche de l'appareil change + -- ensuite, et évite une jointure à chaque affichage de la liste. + callsign varchar(12), + aircraft_type varchar(8), + aircraft_desc varchar(60), + aircraft_wtc varchar(4), + operator_name varchar(120), + country varchar(60), + added_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT watchlist_items_uniq UNIQUE (watchlist_id, icao), + -- Normalisation imposée en base : l'API majuscule déjà, mais une insertion + -- manuelle en minuscules créerait un doublon invisible. + CONSTRAINT watchlist_items_icao_upper CHECK (icao = upper(icao)) +); + +-- Recherche « cet appareil est-il surveillé ? » lors du rendu des tableaux +CREATE INDEX IF NOT EXISTS watchlist_items_icao_idx + ON adsb.watchlist_items (icao); + +COMMENT ON TABLE adsb.watchlist_items IS + 'Aéronefs surveillés, identifiés par leur adresse ICAO 24 bits.'; + +-- Liste par défaut, pour que le bouton « Surveiller » ait une cible dès le +-- premier clic sans imposer la création préalable d'une liste. +INSERT INTO adsb.watchlists (nom, couleur, description) +SELECT 'Surveillance', '#d29922', 'Liste par défaut' +WHERE NOT EXISTS (SELECT 1 FROM adsb.watchlists); + +COMMIT; + +\echo '--- Tables créées ---' +SELECT w.id, w.nom, w.couleur, w.actif, count(i.id) AS appareils +FROM adsb.watchlists w +LEFT JOIN adsb.watchlist_items i ON i.watchlist_id = w.id +GROUP BY w.id, w.nom, w.couleur, w.actif +ORDER BY w.id; diff --git a/webapp/Dockerfile b/webapp/Dockerfile new file mode 100644 index 0000000..d4d0924 --- /dev/null +++ b/webapp/Dockerfile @@ -0,0 +1,71 @@ +FROM php:8.4-apache + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev curl \ + && docker-php-ext-install pgsql pdo pdo_pgsql \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN a2enmod rewrite headers + +RUN sed -i 's/Listen 80$/Listen 8080/' /etc/apache2/ports.conf && \ + sed -i 's///' \ + /etc/apache2/sites-enabled/000-default.conf && \ + echo "ServerName localhost" >> /etc/apache2/apache2.conf + +WORKDIR /var/www/html/assets + +RUN curl -fsSL "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" \ + -o bootstrap.min.css && \ + curl -fsSL "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" \ + -o bootstrap.bundle.min.js && \ + curl -fsSL "https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js" \ + -o chart.umd.min.js && \ + curl -fsSL "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" \ + -o bootstrap-icons.min.css && \ + mkdir -p fonts && \ + curl -fsSL "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2" \ + -o fonts/bootstrap-icons.woff2 && \ + curl -fsSL "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" \ + -o leaflet.js && \ + curl -fsSL "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" \ + -o leaflet.css && \ + mkdir -p leaflet && \ + curl -fsSL "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png" \ + -o leaflet/marker-icon.png && \ + curl -fsSL "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png" \ + -o leaflet/marker-shadow.png + +# ── Drapeaux nationaux (onglets Départs / Zones) ────────────────────────────── +# Source flagcdn.com : domaine public, libre d'usage sans attribution. Les +# drapeaux d'État ne sont pas couverts par le droit d'auteur. +# +# Téléchargés ici plutôt que seulement copiés depuis src/, pour qu'un build sur +# une machine neuve fonctionne sans étape préalable. Le COPY src/ qui suit +# fusionne avec ce dossier : des drapeaux déposés à la main dans +# src/assets/flags/ restent donc pris en compte et écrasent ceux-ci. +# +# Le `|| true` final est délibéré : un drapeau manquant retombe sur le +# pictogramme « pays inconnu » côté JS, ce n'est pas un motif d'échec du build. +RUN mkdir -p flags && cd flags && \ + for c in fr de es it pt be nl lu ch at gb ie dk no se fi is pl cz sk hu ro \ + bg gr hr si rs ba al mt cy ee lv lt ua by ru tr us ca mx br ar cl \ + co pe ve cn jp kr in pk th vn sg my id ph au nz il sa qa ae kw bh \ + om jo lb eg ma dz tn ly za ng ke et gh sn ci cm ga mc sm ad li im \ + gg je bm ky aw bs pa cr do cu jm kz uz az ge am md me mk ir iq sy \ + af bd lk np mm kh la mn tw hk mo ; do \ + curl -fsSL --max-time 10 "https://flagcdn.com/24x18/${c}.png" \ + -o "${c}.png" || rm -f "${c}.png" ; \ + done ; \ + echo "$(ls -1 *.png 2>/dev/null | wc -l) drapeau(x) embarqué(s)" ; \ + true + +WORKDIR /var/www/html +COPY src/ /var/www/html/ + +RUN sed -i 's|AllowOverride None|AllowOverride All|g' \ + /etc/apache2/apache2.conf + +RUN chown -R www-data:www-data /var/www/html + +EXPOSE 8080 +CMD ["apache2-foreground"] diff --git a/webapp/src/.htaccess b/webapp/src/.htaccess new file mode 100644 index 0000000..3ed5a63 --- /dev/null +++ b/webapp/src/.htaccess @@ -0,0 +1,18 @@ +Options -Indexes +RewriteEngine On + +# Toutes les requêtes non-fichiers → index.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] + +# Sécurité +Header always set X-Content-Type-Options "nosniff" +Header always set X-Frame-Options "SAMEORIGIN" +Header always set X-XSS-Protection "1; mode=block" +Header always set Referrer-Policy "strict-origin-when-cross-origin" + +# Cache assets statiques + + Header set Cache-Control "max-age=86400, public" + diff --git a/webapp/src/api.php b/webapp/src/api.php new file mode 100644 index 0000000..1437edc --- /dev/null +++ b/webapp/src/api.php @@ -0,0 +1,1914 @@ +prepare($sql); + $st->execute($p); + return $st->fetchAll(); +} +function q1(string $sql, array $p = []): ?array { + $rows = q($sql, $p); + return $rows[0] ?? null; +} + +// ── Filtre multi-sources (récepteurs) ──────────────────────────────────────── +// La webapp envoie receivers=1,4 ou receivers=1,null (CSV). Le jeton +// « null » vise les positions dont receiver_id n'est pas renseigné : elles +// existent sur toute installation dont la base a tourné avant que la table +// adsb.receivers ne soit peuplée. Sans traitement explicite, un IN (...) les +// écarte silencieusement — trois valeurs logiques SQL — et l'utilisateur +// perd des lignes sans comprendre pourquoi. +// +// Paramètre vide ou absent = aucune restriction (toutes les sources). +// Renvoie un fragment SQL prêt à être concaténé dans un WHERE, et les +// paramètres positionnels correspondants, à insérer AU MÊME ENDROIT dans le +// tableau passé à q(). +function rx_filter($raw): array { + $out = ['sql' => '', 'params' => [], 'ids' => [], 'null' => false]; + $raw = trim((string)$raw); + if ($raw === '') return $out; + + foreach (explode(',', $raw) as $tok) { + $tok = trim($tok); + if ($tok === '') continue; + if (strcasecmp($tok, 'null') === 0) { $out['null'] = true; continue; } + if (ctype_digit($tok)) $out['ids'][] = (int)$tok; + } + $out['ids'] = array_values(array_unique($out['ids'])); + + if ($out['ids'] && $out['null']) { + $out['sql'] = ' AND (receiver_id = ANY(?::int[]) OR receiver_id IS NULL)'; + $out['params'] = ['{' . implode(',', $out['ids']) . '}']; + } elseif ($out['ids']) { + $out['sql'] = ' AND receiver_id = ANY(?::int[])'; + $out['params'] = ['{' . implode(',', $out['ids']) . '}']; + } elseif ($out['null']) { + $out['sql'] = ' AND receiver_id IS NULL'; + } + return $out; +} + +try { + switch ($action) { + + // ── Avions en vue ──────────────────────────────────────────────────── + case 'live': + $data = tar1090_fetch('/data/aircraft.json'); + $aircraft = array_values(array_filter( + $data['aircraft'] ?? [], + fn($ac) => isset($ac['lat']) || isset($ac['altitude']) || isset($ac['alt_baro']) + )); + usort($aircraft, fn($a,$b) => + ($b['alt_baro'] ?? $b['altitude'] ?? 0) <=> ($a['alt_baro'] ?? $a['altitude'] ?? 0) + ); + echo json_encode([ + 'aircraft' => $aircraft, + 'count' => count($aircraft), + 'messages' => $data['messages'] ?? 0, + 'now' => time(), + ]); + break; + + // ── KPI ────────────────────────────────────────────────────────────── + case 'kpi': + $today = q1(" + SELECT COUNT(*) AS sessions, COUNT(DISTINCT icao) AS avions + FROM {$schema}.aircraft_history + WHERE session_start >= date_trunc('day', + NOW() AT TIME ZONE 'Europe/Paris' AT TIME ZONE 'UTC') + "); + $period = q1(" + SELECT COUNT(*) AS sessions, COUNT(DISTINCT icao) AS avions + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + "); + $data = tar1090_fetch('/data/aircraft.json'); + $live = count(array_filter( + $data['aircraft'] ?? [], + fn($ac) => isset($ac['lat']) || isset($ac['altitude']) || isset($ac['alt_baro']) + )); + echo json_encode([ + 'live' => $live, + 'messages' => $data['messages'] ?? 0, + 'today_sess' => (int)($today['sessions'] ?? 0), + 'today_avions' => (int)($today['avions'] ?? 0), + 'period_sess' => (int)($period['sessions'] ?? 0), + 'period_avions' => (int)($period['avions'] ?? 0), + 'period_h' => $period_h, + ]); + break; + + // ── Courbe d'affluence globale ──────────────────────────────────────── + case 'affluence': + $rows = q(" + WITH slots AS ( + SELECT generate_series( + date_trunc('hour', NOW() - INTERVAL '{$period_h} hours') + + floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours') + / {$granularity}) * INTERVAL '{$granularity} minutes', + NOW(), + INTERVAL '{$granularity} minutes' + ) AS slot_start + ), + se AS ( + SELECT slot_start, + slot_start + INTERVAL '{$granularity} minutes' AS slot_end + FROM slots + ) + SELECT + to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label, + COUNT(DISTINCT h.icao) AS nb_avions, + COUNT(h.id) AS nb_sessions + FROM se + LEFT JOIN {$schema}.aircraft_history h + ON h.session_start < se.slot_end + AND h.session_end >= se.slot_start + AND h.session_start >= NOW() - INTERVAL '{$period_h} hours' + GROUP BY se.slot_start + ORDER BY se.slot_start + "); + echo json_encode(['slots' => $rows, 'period_h' => $period_h, 'granularity' => $granularity]); + break; + + // ── Courbe affluence par type d'opérateur ───────────────────────────── + case 'affluence_by_optype': + $rows = q(" + WITH slots AS ( + SELECT generate_series( + date_trunc('hour', NOW() - INTERVAL '{$period_h} hours') + + floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours') + / {$granularity}) * INTERVAL '{$granularity} minutes', + NOW(), + INTERVAL '{$granularity} minutes' + ) AS slot_start + ), + se AS ( + SELECT slot_start, + slot_start + INTERVAL '{$granularity} minutes' AS slot_end + FROM slots + ), + types AS ( + SELECT DISTINCT COALESCE(operator_type, 'unknown') AS op_type + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + ) + SELECT + to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label, + se.slot_start, + COALESCE(h.operator_type, 'unknown') AS op_type, + COUNT(DISTINCT h.icao) AS nb_avions + FROM se + CROSS JOIN types t + LEFT JOIN {$schema}.aircraft_history h + ON h.session_start < se.slot_end + AND h.session_end >= se.slot_start + AND h.session_start >= NOW() - INTERVAL '{$period_h} hours' + AND COALESCE(h.operator_type, 'unknown') = t.op_type + GROUP BY se.slot_start, COALESCE(h.operator_type, 'unknown') + ORDER BY se.slot_start, op_type + "); + echo json_encode(['rows' => $rows, 'period_h' => $period_h]); + break; + + // ── Courbe affluence par type d'aéronef ─────────────────────────────── + case 'affluence_by_aircraft': + $rows = q(" + WITH slots AS ( + SELECT generate_series( + date_trunc('hour', NOW() - INTERVAL '{$period_h} hours') + + floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours') + / {$granularity}) * INTERVAL '{$granularity} minutes', + NOW(), + INTERVAL '{$granularity} minutes' + ) AS slot_start + ), + se AS ( + SELECT slot_start, + slot_start + INTERVAL '{$granularity} minutes' AS slot_end + FROM slots + ), + types AS ( + SELECT DISTINCT COALESCE(aircraft_type, 'unknown') AS ac_type + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + ) + SELECT + to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label, + se.slot_start, + COALESCE(h.aircraft_type, 'unknown') AS ac_type, + COUNT(DISTINCT h.icao) AS nb_avions + FROM se + CROSS JOIN types t + LEFT JOIN {$schema}.aircraft_history h + ON h.session_start < se.slot_end + AND h.session_end >= se.slot_start + AND h.session_start >= NOW() - INTERVAL '{$period_h} hours' + AND COALESCE(h.aircraft_type, 'unknown') = t.ac_type + GROUP BY se.slot_start, COALESCE(h.aircraft_type, 'unknown') + ORDER BY se.slot_start, ac_type + "); + echo json_encode(['rows' => $rows, 'period_h' => $period_h]); + break; + + // ── Stats détaillées par type d'opérateur ──────────────────────────── + case 'stats_by_optype': + $total = q1(" + SELECT COUNT(*) AS t FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + "); + $rows = q(" + SELECT + COALESCE(operator_type, 'unknown') AS type_op, + COUNT(*) AS sessions, + COUNT(DISTINCT icao) AS avions, + ROUND(AVG(duration_seconds)/60.0, 1) AS duree_moy_min, + ROUND(AVG(alt_avg)) AS alt_moy, + ROUND(COUNT(*)*100.0/NULLIF({$total['t']},0),1) AS pct + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + GROUP BY operator_type + ORDER BY sessions DESC + "); + echo json_encode(['rows' => $rows, 'total' => (int)($total['t'] ?? 0)]); + break; + + // ── Stats détaillées par type d'aéronef ────────────────────────────── + case 'stats_by_aircraft': + $total = q1(" + SELECT COUNT(*) AS t FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + "); + $rows = q(" + SELECT + COALESCE(aircraft_type, 'unknown') AS type_ac, + COUNT(*) AS sessions, + COUNT(DISTINCT icao) AS avions, + ROUND(AVG(alt_avg)) AS alt_moy, + ROUND(AVG(speed_avg)) AS vit_moy, + ROUND(COUNT(*)*100.0/NULLIF({$total['t']},0),1) AS pct + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + GROUP BY aircraft_type + ORDER BY sessions DESC + "); + echo json_encode(['rows' => $rows, 'total' => (int)($total['t'] ?? 0)]); + break; + + // ── Dernières sessions ─────────────────────────────────────────────── + case 'history': + $rows = q(" + SELECT + icao, callsign, + COALESCE(operator_name, '—') AS operator_name, + operator_type, aircraft_type, country, + origin, origin_name, destination, destination_name, + to_char(session_start AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI:SS') AS entree, + to_char(session_end AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI:SS') AS sortie, + duration_seconds, position_count, + ROUND(alt_avg) AS alt_avg, ROUND(speed_avg) AS speed_avg, rssi_avg + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + ORDER BY session_end DESC + LIMIT {$limit} + "); + echo json_encode(['sessions' => $rows, 'count' => count($rows)]); + break; + + // ── Top opérateurs ─────────────────────────────────────────────────── + case 'top_operators': + $rows = q(" + SELECT + COALESCE(operator_name,'(' || COALESCE(callsign,'?') || ')') AS operateur, + COALESCE(operator_country,'—') AS pays, + operator_type AS type_op, + COUNT(*) AS sessions, + ROUND(AVG(duration_seconds)/60.0,0) AS duree_moy_min + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + AND (operator_name IS NOT NULL OR callsign IS NOT NULL) + GROUP BY operator_name, callsign, operator_country, operator_type + ORDER BY sessions DESC + LIMIT 20 + "); + echo json_encode(['operators' => $rows]); + break; + + // ── Top pays ───────────────────────────────────────────────────────── + case 'top_countries': + $rows = q(" + WITH tot AS (SELECT COUNT(*) AS t FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours') + SELECT COALESCE(country,'Inconnu') AS pays, + COUNT(*) AS sessions, + ROUND(COUNT(*)*100.0/NULLIF(tot.t,0),1) AS pct + FROM {$schema}.aircraft_history, tot + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + GROUP BY country, tot.t + ORDER BY sessions DESC LIMIT 15 + "); + echo json_encode(['countries' => $rows]); + break; + + // ── Répartition par type d'opérateur (donut) ───────────────────────── + case 'by_type': + $rows = q(" + SELECT COALESCE(operator_type,'unknown') AS type_op, COUNT(*) AS sessions + FROM {$schema}.aircraft_history + WHERE session_start >= NOW() - INTERVAL '{$period_h} hours' + GROUP BY operator_type ORDER BY sessions DESC + "); + echo json_encode(['types' => $rows]); + break; + + + case 'trajectories': + // Trajectoires des 30 dernières minutes depuis adsb.positions + $minutes = max(5, min(60, (int)($_GET['minutes'] ?? 30))); + $rows = q(" + SELECT + icao, callsign, aircraft_type, operator_type, + lat, lon, + CASE WHEN altitude > 0 THEN altitude ELSE NULL END AS altitude, + speed, track, + aircraft_type, aircraft_desc, aircraft_wtc, + to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS heure, + ts + FROM {$schema}.positions + WHERE ts >= NOW() - INTERVAL '{$minutes} minutes' + AND lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN 48.4 AND 49.2 + AND lon BETWEEN 1.8 AND 2.8 + ORDER BY icao, ts + "); + // Grouper par icao + $aircraft = []; + foreach ($rows as $r) { + $icao = $r['icao']; + if (!isset($aircraft[$icao])) { + $aircraft[$icao] = [ + 'icao' => $icao, + 'callsign' => $r['callsign'], + 'aircraft_type' => $r['aircraft_type'], + 'operator_type' => $r['operator_type'], + 'track' => [], + ]; + } + $aircraft[$icao]['track'][] = [ + 'lat' => (float)$r['lat'], + 'lon' => (float)$r['lon'], + 'alt' => (int)$r['altitude'], + 'spd' => (int)$r['speed'], + 'hdg' => (float)$r['track'], + 'ts' => $r['heure'], + ]; + // Enrichir aircraft_desc/wtc depuis la première valeur non vide + if (empty($aircraft[$icao]['desc']) && !empty($r['aircraft_desc'])) { + $aircraft[$icao]['desc'] = $r['aircraft_desc']; + $aircraft[$icao]['wtc'] = $r['aircraft_wtc']; + $aircraft[$icao]['aircraft_type'] = $r['aircraft_type']; + } + } + // Dernière position = position actuelle + foreach ($aircraft as &$ac) { + $last = end($ac['track']); + $ac['lat'] = $last['lat']; + $ac['lon'] = $last['lon']; + $ac['alt'] = $last['alt']; + $ac['spd'] = $last['spd']; + $ac['hdg'] = $last['hdg']; + // desc/wtc déjà enrichis dans la boucle track + } + echo json_encode([ + 'aircraft' => array_values($aircraft), + 'count' => count($aircraft), + 'minutes' => $minutes, + ]); + break; + + + // ── Trajectoires historiques avec agrégation automatique ───────────── + case 'trajectories_history': + $window_min = max(1, min(525600, (int)($_GET['window'] ?? 30))); // fenêtre en minutes + + // Les bornes sont horodatées AVEC leur décalage (ex. « +02:00 »). + // La webapp envoie une heure locale Europe/Paris ; la colonne ts est + // en UTC. Sans offset explicite, PostgreSQL interprète la chaîne dans + // le fuseau de la session (UTC ici) et la fenêtre se retrouve 2 h dans + // le futur — d'où une carte historique systématiquement vide. + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-30 minutes', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-30 minutes', $tz); + } + $to_dt = (clone $from_dt)->modify("+{$window_min} minutes"); + + $from_ts = $from_dt->format('Y-m-d H:i:sP'); // 2026-08-05 19:28:00+02:00 + $to_ts = $to_dt->format('Y-m-d H:i:sP'); + + // Stratégie d'agrégation selon la fenêtre + if ($window_min <= 60) { + // ≤ 1h : positions brutes (5s) + $rows = q(" + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_type, + lat, lon, + CASE WHEN altitude > 0 THEN altitude ELSE NULL END AS altitude, + speed, track AS hdg, + to_char(ts AT TIME ZONE 'Europe/Paris','HH24:MI:SS') AS heure + FROM {$schema}.positions + WHERE ts BETWEEN ? AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN 48.4 AND 49.2 + AND lon BETWEEN 1.8 AND 2.8 + ORDER BY icao, ts + ", [$from_ts, $to_ts]); + } elseif ($window_min <= 360) { + // 1h-6h : 1 point par minute + $rows = q(" + SELECT icao, + MAX(callsign) AS callsign, + MAX(aircraft_type) AS aircraft_type, + MAX(aircraft_desc) AS aircraft_desc, + MAX(aircraft_wtc) AS aircraft_wtc, + MAX(operator_type) AS operator_type, + AVG(lat)::NUMERIC(9,6) AS lat, + AVG(lon)::NUMERIC(9,6) AS lon, + AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude, + AVG(speed)::INT AS speed, + AVG(track)::NUMERIC(5,1) AS hdg, + to_char(date_trunc('minute', ts) AT TIME ZONE 'Europe/Paris','HH24:MI') AS heure + FROM {$schema}.positions + WHERE ts BETWEEN ? AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN 48.4 AND 49.2 + AND lon BETWEEN 1.8 AND 2.8 + GROUP BY icao, date_trunc('minute', ts) + ORDER BY icao, date_trunc('minute', ts) + ", [$from_ts, $to_ts]); + } elseif ($window_min <= 1440) { + // 6h-24h : 1 point par 5 minutes + $rows = q(" + SELECT icao, + MAX(callsign) AS callsign, + MAX(aircraft_type) AS aircraft_type, + MAX(aircraft_desc) AS aircraft_desc, + MAX(aircraft_wtc) AS aircraft_wtc, + MAX(operator_type) AS operator_type, + AVG(lat)::NUMERIC(9,6) AS lat, + AVG(lon)::NUMERIC(9,6) AS lon, + AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude, + AVG(speed)::INT AS speed, + AVG(track)::NUMERIC(5,1) AS hdg, + to_char(date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min' + AT TIME ZONE 'Europe/Paris','HH24:MI') AS heure + FROM {$schema}.positions + WHERE ts BETWEEN ? AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN 48.4 AND 49.2 + AND lon BETWEEN 1.8 AND 2.8 + GROUP BY icao, + date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min' + ORDER BY icao, + date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min' + ", [$from_ts, $to_ts]); + } elseif ($window_min <= 10080) { + // 24h-7j : 1 point par 15 minutes + $rows = q(" + SELECT icao, + MAX(callsign) AS callsign, + MAX(aircraft_type) AS aircraft_type, + MAX(aircraft_desc) AS aircraft_desc, + MAX(aircraft_wtc) AS aircraft_wtc, + MAX(operator_type) AS operator_type, + AVG(lat)::NUMERIC(9,6) AS lat, + AVG(lon)::NUMERIC(9,6) AS lon, + AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude, + AVG(speed)::INT AS speed, + AVG(track)::NUMERIC(5,1) AS hdg, + to_char(date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min' + AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure + FROM {$schema}.positions + WHERE ts BETWEEN ? AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN 48.4 AND 49.2 + AND lon BETWEEN 1.8 AND 2.8 + GROUP BY icao, + date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min' + ORDER BY icao, + date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min' + ", [$from_ts, $to_ts]); + } else { + // > 7j : depuis aircraft_history (sessions), 1 segment départ→arrivée + $rows = q(" + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_type, + first_lat AS lat, first_lon AS lon, + alt_avg::INT AS altitude, + speed_avg::INT AS speed, + NULL::NUMERIC AS hdg, + to_char(session_start AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure, + last_lat, last_lon, + to_char(session_end AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure_fin + FROM {$schema}.aircraft_history + WHERE session_start BETWEEN ? AND ? + AND ( + (first_lat BETWEEN 48.4 AND 49.2 AND first_lon BETWEEN 1.8 AND 2.8) + OR + (last_lat BETWEEN 48.4 AND 49.2 AND last_lon BETWEEN 1.8 AND 2.8) + ) + ORDER BY icao, session_start + ", [$from_ts, $to_ts]); + } + + // Grouper par icao (même logique que trajectories) + $use_history = ($window_min > 10080); + $aircraft = []; + foreach ($rows as $r) { + $icao = $r['icao']; + if (!isset($aircraft[$icao])) { + $aircraft[$icao] = [ + 'icao' => $icao, + 'callsign' => $r['callsign'], + 'aircraft_type' => $r['aircraft_type'], + 'aircraft_desc' => $r['aircraft_desc'] ?? '', + 'desc' => $r['aircraft_desc'] ?? '', + 'wtc' => $r['aircraft_wtc'] ?? '', + 'operator_type' => $r['operator_type'], + 'track' => [], + ]; + } + if ($use_history) { + // Mode history : 2 points (départ + arrivée) + if (!empty($r['lat']) && !empty($r['lon'])) { + $aircraft[$icao]['track'][] = [ + 'lat'=>(float)$r['lat'], 'lon'=>(float)$r['lon'], + 'alt'=>(int)$r['altitude'], 'spd'=>(int)$r['speed'], + 'hdg'=>0, 'ts'=>$r['heure'] + ]; + } + if (!empty($r['last_lat']) && !empty($r['last_lon'])) { + $aircraft[$icao]['track'][] = [ + 'lat'=>(float)$r['last_lat'], 'lon'=>(float)$r['last_lon'], + 'alt'=>(int)$r['altitude'], 'spd'=>(int)$r['speed'], + 'hdg'=>0, 'ts'=>$r['heure_fin'] + ]; + } + } else { + $aircraft[$icao]['track'][] = [ + 'lat'=>(float)$r['lat'], 'lon'=>(float)$r['lon'], + 'alt'=>(int)($r['altitude']??0), 'spd'=>(int)($r['speed']??0), + 'hdg'=>(float)($r['hdg']??0), 'ts'=>$r['heure'] + ]; + if (empty($aircraft[$icao]['desc']) && !empty($r['aircraft_desc'])) { + $aircraft[$icao]['desc'] = $r['aircraft_desc']; + $aircraft[$icao]['wtc'] = $r['aircraft_wtc'] ?? ''; + } + } + } + + // Calculer position courante (dernière du track) + foreach ($aircraft as &$ac) { + if (!empty($ac['track'])) { + $last = end($ac['track']); + $ac['lat'] = $last['lat']; + $ac['lon'] = $last['lon']; + $ac['alt'] = $last['alt']; + $ac['spd'] = $last['spd']; + $ac['hdg'] = $last['hdg']; + } + } + + echo json_encode([ + 'aircraft' => array_values($aircraft), + 'count' => count($aircraft), + 'window_min' => $window_min, + 'from' => $from_ts, + 'to' => $to_ts, + 'strategy' => $window_min <= 60 ? 'raw_5s' + : ($window_min <= 360 ? 'agg_1min' + : ($window_min <= 1440 ? 'agg_5min' + : ($window_min <= 10080? 'agg_15min' + : 'history_sessions'))), + ]); + break; + + // ── Liste des aérodromes servant de zones d'exclusion ──────────────── + case 'airports': + echo json_encode([ + 'airports' => q(" + SELECT id, code, nom, lat, lon, rayon_km, type, actif, + commentaire, source, + to_char(cree_le AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS cree_le, + to_char(modifie_le AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY HH24:MI') AS modifie_le + FROM {$schema}.airports + ORDER BY actif DESC, source, rayon_km DESC, code + "), + ]); + break; + + // ═══════════════════════════════════════════════════════════════════ + // LISTES DE SURVEILLANCE + // ═══════════════════════════════════════════════════════════════════ + + // Listes et leur effectif. Sert au sélecteur de l'onglet Surveillance + // comme à la modale d'ajout depuis les tableaux de départs. + case 'watchlists': + echo json_encode(['lists' => q(" + SELECT w.id, w.nom, w.couleur, w.description, w.actif, + count(i.id) AS appareils, + to_char(w.created_at AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY') AS created_at + FROM {$schema}.watchlists w + LEFT JOIN {$schema}.watchlist_items i ON i.watchlist_id = w.id + GROUP BY w.id, w.nom, w.couleur, w.description, w.actif, w.created_at + ORDER BY w.nom + ")]); + break; + + // Création ou renommage d'une liste + case 'watchlist_save': + $id = (int)($_POST['id'] ?? 0); + $nom = trim((string)($_POST['nom'] ?? '')); + $col = trim((string)($_POST['couleur'] ?? '#d29922')); + $desc = trim((string)($_POST['description'] ?? '')); + + if ($nom === '') { + http_response_code(400); + echo json_encode(['error' => 'Le nom de la liste est obligatoire']); + break; + } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $col)) $col = '#d29922'; + + try { + if ($id > 0) { + q("UPDATE {$schema}.watchlists + SET nom = ?, couleur = ?, description = ? + WHERE id = ?", [$nom, $col, $desc ?: null, $id]); + } else { + $row = q1("INSERT INTO {$schema}.watchlists (nom, couleur, description) + VALUES (?, ?, ?) RETURNING id", [$nom, $col, $desc ?: null]); + $id = (int)$row['id']; + } + echo json_encode(['ok' => true, 'id' => $id]); + } catch (PDOException $e) { + // 23505 = violation d'unicité sur watchlists.nom + http_response_code(409); + echo json_encode(['error' => $e->getCode() === '23505' + ? "Une liste porte déjà le nom « {$nom} »" + : $e->getMessage()]); + } + break; + + // Suppression d'une liste. ON DELETE CASCADE emporte ses membres : + // l'appelant doit donc confirmer, le compte est renvoyé pour l'alerte. + case 'watchlist_delete': + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { http_response_code(400); echo json_encode(['error' => 'id manquant']); break; } + $n = q1("SELECT count(*) AS n FROM {$schema}.watchlist_items WHERE watchlist_id = ?", [$id]); + q("DELETE FROM {$schema}.watchlists WHERE id = ?", [$id]); + echo json_encode(['ok' => true, 'removed' => (int)($n['n'] ?? 0)]); + break; + + // Mise sous surveillance. Idempotent : réajouter un appareil déjà + // présent met à jour le motif plutôt que d'échouer sur la contrainte + // d'unicité — un double clic ne doit pas produire une erreur. + case 'watchlist_add': + $wid = (int)($_POST['watchlist_id'] ?? 0); + $icao = strtoupper(trim((string)($_POST['icao'] ?? ''))); + $motif = trim((string)($_POST['motif'] ?? '')); + + if (!preg_match('/^[0-9A-F]{6}$/', $icao)) { + http_response_code(400); + echo json_encode(['error' => "Adresse ICAO invalide : « {$icao} »"]); + break; + } + if ($wid <= 0) { http_response_code(400); echo json_encode(['error' => 'Liste non précisée']); break; } + + q("INSERT INTO {$schema}.watchlist_items + (watchlist_id, icao, motif, callsign, aircraft_type, + aircraft_desc, aircraft_wtc, operator_name, country) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (watchlist_id, icao) DO UPDATE + SET motif = COALESCE(NULLIF(EXCLUDED.motif, ''), {$schema}.watchlist_items.motif)", + [ + $wid, $icao, $motif ?: null, + substr(trim((string)($_POST['callsign'] ?? '')), 0, 12) ?: null, + substr(trim((string)($_POST['aircraft_type'] ?? '')), 0, 8) ?: null, + substr(trim((string)($_POST['aircraft_desc'] ?? '')), 0, 60) ?: null, + substr(trim((string)($_POST['aircraft_wtc'] ?? '')), 0, 4) ?: null, + substr(trim((string)($_POST['operator_name'] ?? '')), 0, 120) ?: null, + substr(trim((string)($_POST['country'] ?? '')), 0, 60) ?: null, + ]); + echo json_encode(['ok' => true, 'icao' => $icao]); + break; + + // Retrait. Sans watchlist_id, l'appareil sort de TOUTES les listes : + // c'est ce que fait la bascule depuis les tableaux de départs, où + // l'utilisateur ne voit pas dans quelle liste l'appareil se trouve. + case 'watchlist_remove': + $icao = strtoupper(trim((string)($_POST['icao'] ?? ''))); + $wid = (int)($_POST['watchlist_id'] ?? 0); + if (!preg_match('/^[0-9A-F]{6}$/', $icao)) { + http_response_code(400); + echo json_encode(['error' => 'Adresse ICAO invalide']); + break; + } + if ($wid > 0) { + q("DELETE FROM {$schema}.watchlist_items + WHERE icao = ? AND watchlist_id = ?", [$icao, $wid]); + } else { + q("DELETE FROM {$schema}.watchlist_items WHERE icao = ?", [$icao]); + } + echo json_encode(['ok' => true]); + break; + + // Contenu d'une liste (ou de toutes), enrichi des statistiques de la + // période : nombre de départs hors aérodrome détectés, dernier contact + // et point d'ancrage pour l'affichage de la trajectoire. + // + // La détection reprend exactement la logique de departures_offfield, + // mais restreinte aux ICAO surveillés — c'est ce qui la rend abordable + // sur une longue période : la table est attaquée par l'index sur icao + // plutôt que balayée en entier. + case 'watchlist_items': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-30 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $wid = (int)($_GET['watchlist_id'] ?? 0); + $rx = rx_filter($_GET['receivers'] ?? ''); + + $where_list = $wid > 0 ? 'WHERE i.watchlist_id = ?' : ''; + + $sql = " + WITH sel AS ( + SELECT i.id, i.watchlist_id, i.icao, i.motif, i.callsign, + i.aircraft_type, i.aircraft_desc, i.aircraft_wtc, + i.operator_name, i.country, i.added_at, + w.nom AS liste_nom, w.couleur AS liste_couleur + FROM {$schema}.watchlist_items i + JOIN {$schema}.watchlists w ON w.id = i.watchlist_id + {$where_list} + ), + icaos AS (SELECT DISTINCT icao FROM sel), + pts AS ( + SELECT icao, ts, altitude, vrate, lat, lon, geom, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND icao IN (SELECT icao FROM icaos) + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + hors AS ( + SELECT * FROM bas b + WHERE b.vrate >= ? + AND NOT EXISTS ( + SELECT 1 FROM {$schema}.airports a + WHERE a.actif + AND ST_DWithin(b.geom::geography, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ) + ), + dep AS ( + SELECT DISTINCT ON (icao) + icao, count(*) OVER (PARTITION BY icao) AS departs, + ts AS dep_ts, lat AS dep_lat, lon AS dep_lon, + altitude AS dep_alt + FROM hors + ORDER BY icao, ts DESC + ), + der AS ( + SELECT DISTINCT ON (icao) icao, ts, lat, lon, altitude + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND icao IN (SELECT icao FROM icaos) + ORDER BY icao, ts DESC + ) + SELECT s.*, + to_char(s.added_at AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI') AS added_local, + COALESCE(d.departs, 0) AS departs, + to_char(d.dep_ts AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI:SS') AS dep_ts_local, + d.dep_lat, d.dep_lon, d.dep_alt, + to_char(r.ts AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI:SS') AS last_ts_local, + r.lat AS last_lat, r.lon AS last_lon, + r.altitude AS last_alt + FROM sel s + LEFT JOIN dep d ON d.icao = s.icao + LEFT JOIN der r ON r.icao = s.icao + ORDER BY COALESCE(d.departs, 0) DESC, s.added_at DESC + "; + + $rows = q($sql, array_merge( + $wid > 0 ? [$wid] : [], + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + ], + $rx['params'], + [ + $gap_min, + $vrate, + $margin, + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + ] + )); + + echo json_encode([ + 'items' => $rows, + 'count' => count($rows), + 'from' => $from_dt->format('Y-m-d H:i:sP'), + 'to' => $to_dt->format('Y-m-d H:i:sP'), + ]); + break; + + // Simple ensemble des ICAO surveillés, pour colorer les boutons des + // tableaux de départs sans rapatrier toutes les métadonnées. + case 'watchlist_icaos': + $rows = q("SELECT DISTINCT icao FROM {$schema}.watchlist_items"); + echo json_encode(['icaos' => array_column($rows, 'icao')]); + break; + + // ── Sources de données disponibles (récepteurs) ────────────────────── + // Alimente le menu déroulant de sélection des dongles. Deux principes : + // + // 1. La liste proposée est celle des récepteurs AYANT RÉELLEMENT DES + // POINTS sur la fenêtre demandée, pas le contenu de adsb.receivers. + // L'inserter crée automatiquement une fiche à la découverte d'un + // serial inconnu : sur une installation partagée, la table se + // remplit de récepteurs fantômes (dongle branché puis retiré, + // serial erroné dans .env.receivers) qui n'ont jamais rien produit. + // Les proposer au filtrage n'aurait aucun sens. + // + // 2. Les positions à receiver_id NULL sont exposées comme une source à + // part entière. Elles apparaissent sur toute base ayant tourné avant + // que adsb.receivers ne soit peuplée. + // + // Les fiches déclarées sans données sont renvoyées séparément (clé + // « declared_unused ») : l'interface peut les signaler sans les mêler + // aux sources sélectionnables. + case 'receivers': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-7 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-7 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 10080))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $used = q(" + WITH used AS ( + SELECT receiver_id, COUNT(*) AS pts, + MIN(ts) AS premier, MAX(ts) AS dernier + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + GROUP BY receiver_id + ) + SELECT u.receiver_id AS id, + u.pts, + to_char(u.premier AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI') AS premier, + to_char(u.dernier AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI') AS dernier, + r.serial, r.model, r.chipset, r.location_desc, + r.lat, r.lon, r.active, + COALESCE(NULLIF(r.name, ''), + 'Dongle ' || r.serial, + 'Source non identifiée') AS name + FROM used u + LEFT JOIN {$schema}.receivers r ON r.id = u.receiver_id + ORDER BY u.pts DESC + ", [$from_dt->format('Y-m-d H:i:sP'), $to_dt->format('Y-m-d H:i:sP')]); + + $seen = []; + foreach ($used as $u) { if ($u['id'] !== null) $seen[] = (int)$u['id']; } + + $declared = q(" + SELECT id, serial, model, chipset, location_desc, lat, lon, active, + COALESCE(NULLIF(name, ''), 'Dongle ' || serial) AS name, + to_char(installed_at AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY') AS installed_at + FROM {$schema}.receivers + ORDER BY id + "); + $unused = array_values(array_filter( + $declared, fn($d) => !in_array((int)$d['id'], $seen, true) + )); + + echo json_encode([ + 'receivers' => $used, + 'declared_unused' => $unused, + 'from' => $from_dt->format('Y-m-d H:i:sP'), + 'to' => $to_dt->format('Y-m-d H:i:sP'), + ]); + break; + + // ── Bilan par zone connue ─────────────────────────────────────────── + // Miroir de departures_offfield : au lieu d'écarter les départs situés + // dans une zone d'exclusion, on les COMPTE par zone. Répond à « combien + // d'appareils ont décollé de Villacoublay sur la période ? ». + // + // Une ligne de synthèse « hors zone » est ajoutée pour les départs qui + // ne tombent dans aucune zone active — ce sont exactement ceux que la + // carte affiche en cellules. + case 'departures_by_zone': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-14 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-14 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 20160))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $rx = rx_filter($_GET['receivers'] ?? ''); + + // Les deux requêtes partagent la même préparation : on la nomme + // une fois plutôt que de la dupliquer ou de la retoucher par + // remplacement de chaîne, ce qui casserait au moindre écart + // d'indentation. + $cte = " + WITH pts AS ( + SELECT icao, ts, altitude, vrate, geom, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + dep AS (SELECT * FROM bas WHERE vrate >= ?), + -- Rattachement d'un départ à sa zone. Un point peut tomber dans + -- deux zones qui se chevauchent — Issy et l'Élysée sont à 3 km + -- l'un de l'autre : DISTINCT ON retient la plus proche du + -- centre, sans quoi le même décollage serait compté deux fois. + rat AS ( + SELECT DISTINCT ON (d.icao, d.seg_id) + d.icao, d.seg_id, d.ts, d.altitude, d.vrate, + a.id AS zone_id + FROM dep d + LEFT JOIN {$schema}.airports a + ON a.actif + AND ST_DWithin(d.geom, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ORDER BY d.icao, d.seg_id, + ST_Distance(d.geom, a.geom) NULLS LAST + ) + "; + + $params = array_merge( + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + ], + $rx['params'], + [$gap_min, $vrate, $margin] + ); + + $rows = q($cte . " + SELECT a.id, a.code, a.nom, a.type, a.rayon_km, a.actif, + a.lat, a.lon, a.source, + COUNT(r.icao) AS departs, + COUNT(DISTINCT r.icao) AS appareils, + to_char(MAX(r.ts) AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI') AS dernier, + ROUND(AVG(r.altitude)::numeric, 0) AS alt_moy, + ROUND(AVG(r.vrate)::numeric, 0) AS vrate_moy + FROM {$schema}.airports a + LEFT JOIN rat r ON r.zone_id = a.id + WHERE a.actif + GROUP BY a.id, a.code, a.nom, a.type, a.rayon_km, a.actif, + a.lat, a.lon, a.source + ORDER BY departs DESC, a.rayon_km DESC + ", $params); + + // Départs ne tombant dans aucune zone active : ce sont exactement + // ceux que la carte agrège en cellules. + $hors = q1($cte . " + SELECT COUNT(*) AS departs, COUNT(DISTINCT icao) AS appareils, + to_char(MAX(ts) AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI') AS dernier + FROM rat WHERE zone_id IS NULL + ", $params); + + echo json_encode([ + 'zones' => $rows, + 'hors_zone' => $hors, + 'from' => $from_dt->format('Y-m-d H:i:sP'), + 'to' => $to_dt->format('Y-m-d H:i:sP'), + 'params' => ['alt_max' => $alt_max, 'vrate' => $vrate, + 'gap' => $gap_min, 'margin' => $margin], + ]); + break; + + // ── Derniers appareils rattachés à une zone connue ────────────────── + // Alimente le tableau de détail du bilan. zone_id = 'null' vise les + // départs hors de toute zone active. + case 'zone_aircraft': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-14 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-14 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 20160))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $lim = max(1, min(200, (int)($_GET['limit'] ?? 8))); + $rx = rx_filter($_GET['receivers'] ?? ''); + + $zid = $_GET['zone_id'] ?? ''; + $horszone = (strcasecmp((string)$zid, 'null') === 0); + $zid_int = (int)$zid; + + $where_zone = $horszone ? 'zone_id IS NULL' : 'zone_id = ?'; + + $sql = " + WITH pts AS ( + SELECT icao, callsign, aircraft_type, aircraft_desc, + aircraft_wtc, operator_name, country, + ts, altitude, vrate, speed, lat, lon, geom, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + dep AS (SELECT * FROM bas WHERE vrate >= ?), + rat AS ( + SELECT DISTINCT ON (d.icao, d.seg_id) + d.icao, d.callsign, d.aircraft_type, d.aircraft_desc, + d.aircraft_wtc, d.operator_name, d.country, + d.ts, d.altitude, d.vrate, d.speed, d.lat, d.lon, + a.id AS zone_id + FROM dep d + LEFT JOIN {$schema}.airports a + ON a.actif + AND ST_DWithin(d.geom, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ORDER BY d.icao, d.seg_id, + ST_Distance(d.geom, a.geom) NULLS LAST + ) + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_name, country, altitude, vrate, speed, lat, lon, + ts, + to_char(ts AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI:SS') AS ts_local + FROM rat + WHERE {$where_zone} + ORDER BY ts DESC + LIMIT ? + "; + + $params = array_merge( + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + ], + $rx['params'], + [$gap_min, $vrate, $margin], + $horszone ? [] : [$zid_int], + [$lim] + ); + + echo json_encode(['aircraft' => q($sql, $params)]); + break; + + // ── Détail d'une zone : aéronefs détectés dans la cellule ──────────── + // Rejoue la même détection que departures_offfield_zones, mais bornée + // à une seule cellule, pour lister les appareils concernés. + case 'departures_offfield_zone_detail': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-30 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $cell_km = max(0.5, min(20, (float)($_GET['cell'] ?? 2))); + $c_lat = (float)($_GET['lat'] ?? 0); + $c_lon = (float)($_GET['lon'] ?? 0); + + // Demi-côté de la cellule, en degrés + $half_lat = ($cell_km / 111.0) / 2; + $half_lon = ($cell_km / 73.0) / 2; + + $rx = rx_filter($_GET['receivers'] ?? ''); + + $sql = " + WITH pts AS ( + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_name, operator_type, country, receiver_id, + origin, origin_name, destination, destination_name, + ts, altitude, vrate, speed, track, lat, lon, geom, squawk, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat BETWEEN ? AND ? + AND lon BETWEEN ? AND ? + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + segb AS ( + SELECT *, + MIN(ts) OVER (PARTITION BY icao, seg_id) AS seg_t0, + MAX(ts) OVER (PARTITION BY icao, seg_id) AS seg_t1 + FROM seg + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM segb ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + srcs AS ( + SELECT icao, seg_id, + string_agg(DISTINCT COALESCE(receiver_id::text, 'null'), ',') + AS sources + FROM seg GROUP BY icao, seg_id + ), + hors AS ( + SELECT b.* FROM bas b + WHERE b.vrate >= ? + AND NOT EXISTS ( + SELECT 1 FROM {$schema}.airports a + WHERE a.actif + AND ST_DWithin(b.geom::geography, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ) + ) + SELECT h.icao, h.callsign, h.aircraft_type, h.aircraft_desc, + h.aircraft_wtc, h.operator_name, h.operator_type, h.country, + h.origin, h.origin_name, h.destination, h.destination_name, + h.squawk, h.altitude, h.vrate, h.speed, h.track, + h.lat, h.lon, + h.receiver_id, s.sources, + COALESCE(NULLIF(r.name, ''), 'Dongle ' || r.serial, + 'Source non identifiée') AS receiver_name, + to_char(h.ts AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI:SS') AS ts_local, + h.ts, + p.n_pts_seg, p.alt_debut, p.alt_fin, + p.alt_min_seg, p.alt_max_seg, p.duree_min, + CASE + WHEN p.n_pts_seg < 3 OR p.duree_min < 1 THEN 'indetermine' + WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000 + AND (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000 + THEN 'mixte' + WHEN (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000 + THEN 'creux' + WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000 + THEN 'bosse' + WHEN p.alt_fin - p.alt_debut >= 1000 THEN 'montee' + WHEN p.alt_fin - p.alt_debut <= -1000 THEN 'descente' + ELSE 'palier' + END AS phase + FROM hors h + LEFT JOIN srcs s ON s.icao = h.icao AND s.seg_id = h.seg_id + LEFT JOIN {$schema}.receivers r ON r.id = h.receiver_id + LEFT JOIN LATERAL ( + SELECT count(*) AS n_pts_seg, + MIN(q.altitude) AS alt_min_seg, + MAX(q.altitude) AS alt_max_seg, + (array_agg(q.altitude ORDER BY q.ts ASC))[1] AS alt_debut, + (array_agg(q.altitude ORDER BY q.ts DESC))[1] AS alt_fin, + ROUND((EXTRACT(EPOCH FROM (MAX(q.ts) - MIN(q.ts))) + / 60.0)::numeric, 1) AS duree_min + FROM {$schema}.positions q + WHERE q.icao = h.icao + AND q.altitude IS NOT NULL + AND q.ts >= h.seg_t0 - (? || ' minutes')::interval + AND q.ts <= h.seg_t1 + (? || ' minutes')::interval + ) p ON true + ORDER BY h.ts DESC + LIMIT 200 + "; + + $rows = q($sql, array_merge( + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + $c_lat - $half_lat, $c_lat + $half_lat, + $c_lon - $half_lon, $c_lon + $half_lon, + ], + $rx['params'], + [ + $gap_min, // seg + $vrate, $margin, // hors + $gap_min, $gap_min, // fenêtre élargie du profil + ] + )); + + // La zone est-elle déjà couverte par une exclusion ? + $covered = q1(" + SELECT a.id, a.code, a.nom, a.rayon_km, a.actif, + ROUND((ST_Distance( + ST_SetSRID(ST_MakePoint(?, ?),4326)::geography, + a.geom)/1000.0)::numeric, 2) AS dist_km + FROM {$schema}.airports a + WHERE ST_DWithin(ST_SetSRID(ST_MakePoint(?, ?),4326)::geography, + a.geom, a.rayon_km * 1000) + ORDER BY a.geom <-> ST_SetSRID(ST_MakePoint(?, ?),4326)::geography + LIMIT 1 + ", [$c_lon, $c_lat, $c_lon, $c_lat, $c_lon, $c_lat]); + + echo json_encode([ + 'aircraft' => $rows, + 'count' => count($rows), + 'lat' => $c_lat, + 'lon' => $c_lon, + 'cell_km' => $cell_km, + 'covered' => $covered ?: null, + ]); + break; + + // ── Trajectoire d'un aéronef autour d'un instant donné ─────────────── + // Sert au panneau de détail : tracé sur carte + profil d'altitude. + // La fenêtre est centrée sur l'horodatage du point de départ détecté. + case 'aircraft_track': + $icao = preg_replace('/[^0-9A-Fa-f]/', '', $_GET['icao'] ?? ''); + if ($icao === '') { + http_response_code(400); + echo json_encode(['error' => 'Paramètre icao requis']); + break; + } + + $tz = new DateTimeZone('Europe/Paris'); + try { + $ref_dt = new DateTime($_GET['ts'] ?? 'now', $tz); + } catch (Exception $e) { + $ref_dt = new DateTime('now', $tz); + } + // Fenêtre asymétrique : on veut surtout ce qui suit le décollage + $before = max(1, min(120, (int)($_GET['before'] ?? 15))); + $after = max(1, min(240, (int)($_GET['after'] ?? 45))); + + $t0 = (clone $ref_dt)->modify("-{$before} minutes"); + $t1 = (clone $ref_dt)->modify("+{$after} minutes"); + + $rows = q(" + SELECT ts, + to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS hhmm, + EXTRACT(EPOCH FROM (ts - ?::timestamptz))::int AS dt_sec, + lat, lon, altitude, speed, track, vrate, callsign, squawk + FROM {$schema}.positions + WHERE icao = ? + AND ts >= ? AND ts <= ? + AND lat IS NOT NULL AND lon IS NOT NULL + ORDER BY ts + ", [$ref_dt->format('Y-m-d H:i:sP'), strtoupper($icao), + $t0->format('Y-m-d H:i:sP'), $t1->format('Y-m-d H:i:sP')]); + + // Fiche appareil, prise sur le point le plus renseigné + $info = q1(" + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_name, operator_type, country, + origin, origin_name, destination, destination_name + FROM {$schema}.positions + WHERE icao = ? AND ts >= ? AND ts <= ? + ORDER BY (callsign IS NOT NULL) DESC, + (operator_name IS NOT NULL) DESC, ts + LIMIT 1 + ", [strtoupper($icao), $t0->format('Y-m-d H:i:sP'), $t1->format('Y-m-d H:i:sP')]); + + echo json_encode([ + 'icao' => strtoupper($icao), + 'info' => $info ?: null, + 'track' => $rows, + 'count' => count($rows), + 'ref_ts' => $ref_dt->format('Y-m-d H:i:sP'), + 'from' => $t0->format('Y-m-d H:i:sP'), + 'to' => $t1->format('Y-m-d H:i:sP'), + ]); + break; + + // ── Création / modification d'une zone d'exclusion ─────────────────── + // Accepte GET ou POST. Sans id : création. Avec id : mise à jour. + // Les zones ajoutées ici portent source='manuelle' et peuvent être + // supprimées, contrairement aux aérodromes de référence. + case 'airport_save': + $in = array_merge($_GET, $_POST); + + $id = isset($in['id']) && $in['id'] !== '' ? (int)$in['id'] : null; + $nom = trim($in['nom'] ?? ''); + $lat = isset($in['lat']) ? (float)$in['lat'] : null; + $lon = isset($in['lon']) ? (float)$in['lon'] : null; + $rayon = isset($in['rayon_km']) ? (float)$in['rayon_km'] : 5.0; + $type = trim($in['type'] ?? 'inconnu'); + $comm = trim($in['commentaire'] ?? ''); + $actif = isset($in['actif']) + ? filter_var($in['actif'], FILTER_VALIDATE_BOOLEAN) : true; + + if ($rayon <= 0 || $rayon > 100) { + http_response_code(400); + echo json_encode(['error' => 'Rayon hors bornes (0 < rayon ≤ 100 km)']); + break; + } + + if ($id) { + // Mise à jour : sur une fiche de référence, seuls le rayon, + // l'état actif et le commentaire sont modifiables — le nom et + // les coordonnées d'un aérodrome officiel ne doivent pas dériver. + $cur = q1("SELECT source FROM {$schema}.airports WHERE id = ?", [$id]); + if (!$cur) { + http_response_code(404); + echo json_encode(['error' => 'Zone introuvable']); + break; + } + + if ($cur['source'] === 'reference') { + q("UPDATE {$schema}.airports + SET rayon_km = ?, actif = ?, commentaire = ? + WHERE id = ?", [$rayon, $actif ? 't' : 'f', $comm, $id]); + } else { + if ($nom === '' || $lat === null || $lon === null) { + http_response_code(400); + echo json_encode(['error' => 'Nom, latitude et longitude requis']); + break; + } + q("UPDATE {$schema}.airports + SET nom = ?, lat = ?, lon = ?, rayon_km = ?, + type = ?, actif = ?, commentaire = ? + WHERE id = ?", + [$nom, $lat, $lon, $rayon, $type, $actif ? 't' : 'f', $comm, $id]); + } + + echo json_encode(['ok' => true, 'id' => $id, 'mode' => 'update']); + + } else { + if ($nom === '' || $lat === null || $lon === null) { + http_response_code(400); + echo json_encode(['error' => 'Nom, latitude et longitude requis']); + break; + } + + // Code interne unique, non OACI : Z001, Z002… + $next = q1("SELECT COALESCE(MAX(NULLIF(regexp_replace(code,'\\D','','g'),'')::int),0)+1 AS n + FROM {$schema}.airports WHERE code ~ '^Z[0-9]+$'"); + $code = sprintf('Z%03d', $next['n'] ?? 1); + + $row = q1("INSERT INTO {$schema}.airports + (code, nom, lat, lon, rayon_km, type, actif, commentaire, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manuelle') + RETURNING id", + [$code, $nom, $lat, $lon, $rayon, $type, + $actif ? 't' : 'f', $comm]); + + echo json_encode(['ok' => true, 'id' => $row['id'] ?? null, + 'code' => $code, 'mode' => 'insert']); + } + break; + + // ── Suppression d'une zone manuelle ────────────────────────────────── + // Les aérodromes de référence ne sont jamais supprimés : on les + // désactive (actif=false), ce qui les retire de l'exclusion tout en + // conservant la fiche pour une réactivation ultérieure. + // Réglage groupé des rayons, écrit en base — à ne pas confondre avec + // le sélecteur « Marge » de la barre d'outils, qui applique un ajout + // au moment de la requête sans rien persister. + // + // Trois modes. « delta » et « facteur » préservent la hiérarchie entre + // un aéroport à 15 km et une hélisurface à 300 m ; « set » l'écrase, + // d'où la confirmation exigée côté interface. + case 'airports_bulk_radius': + $mode = $_POST['mode'] ?? ''; + $val = (float)($_POST['value'] ?? 0); + $scope = $_POST['scope'] ?? 'all'; + + if (!in_array($mode, ['set', 'delta', 'factor'], true)) { + http_response_code(400); + echo json_encode(['error' => 'Mode inconnu']); + break; + } + if ($mode === 'set' && ($val <= 0 || $val > 100)) { + http_response_code(400); + echo json_encode(['error' => 'Rayon hors bornes (0 < r ≤ 100 km)']); + break; + } + if ($mode === 'factor' && ($val <= 0 || $val > 20)) { + http_response_code(400); + echo json_encode(['error' => 'Facteur hors bornes (0 < f ≤ 20)']); + break; + } + if ($mode === 'delta' && abs($val) > 100) { + http_response_code(400); + echo json_encode(['error' => 'Delta hors bornes (±100 km)']); + break; + } + + $expr = [ + 'set' => '?', + 'delta' => 'rayon_km + ?', + 'factor' => 'rayon_km * ?', + ][$mode]; + + $where = [ + 'all' => '1=1', + 'actives' => 'actif', + 'reference' => "source = 'reference'", + 'manuelle' => "source = 'manuelle'", + ][$scope] ?? '1=1'; + + // Bornage en SQL : la contrainte airports_rayon_chk rejetterait + // tout l'UPDATE si une seule ligne sortait de l'intervalle, et un + // delta négatif appliqué à une hélisurface de 300 m y arriverait + // immédiatement. + $rows = q(" + UPDATE {$schema}.airports + SET rayon_km = LEAST(100.0, GREATEST(0.1, {$expr})) + WHERE {$where} + RETURNING id, code, nom, rayon_km + ", [$val]); + + echo json_encode([ + 'ok' => true, + 'updated' => count($rows), + 'mode' => $mode, + 'value' => $val, + 'scope' => $scope, + 'rows' => $rows, + ]); + break; + + case 'airport_delete': + $in = array_merge($_GET, $_POST); + $id = isset($in['id']) ? (int)$in['id'] : 0; + + $cur = q1("SELECT source, code, nom FROM {$schema}.airports WHERE id = ?", [$id]); + if (!$cur) { + http_response_code(404); + echo json_encode(['error' => 'Zone introuvable']); + break; + } + + if ($cur['source'] === 'reference') { + q("UPDATE {$schema}.airports SET actif = false WHERE id = ?", [$id]); + echo json_encode([ + 'ok' => true, 'mode' => 'deactivated', + 'message' => "{$cur['code']} est un aérodrome de référence : " + . "désactivé plutôt que supprimé", + ]); + } else { + q("DELETE FROM {$schema}.airports WHERE id = ?", [$id]); + echo json_encode(['ok' => true, 'mode' => 'deleted', + 'message' => "Zone {$cur['nom']} supprimée"]); + } + break; + + // ── Activation / désactivation rapide ──────────────────────────────── + case 'airport_toggle': + $in = array_merge($_GET, $_POST); + $id = isset($in['id']) ? (int)$in['id'] : 0; + $row = q1("UPDATE {$schema}.airports SET actif = NOT actif + WHERE id = ? RETURNING id, code, actif", [$id]); + if (!$row) { + http_response_code(404); + echo json_encode(['error' => 'Zone introuvable']); + break; + } + echo json_encode(['ok' => true, 'id' => $row['id'], + 'code' => $row['code'], 'actif' => $row['actif']]); + break; + + + // ── Départs détectés HORS des aérodromes connus ────────────────────── + // Principe : on segmente la trace de chaque appareil sur les trous de + // plus de $gap minutes, on prend le point le PLUS BAS de chaque segment, + // on ne garde que ceux en montée franche, puis on écarte tout ce qui + // tombe dans le rayon d'un aérodrome actif. + // + // Limite physique à garder en tête : un appareil décollant à 40 km est + // masqué par l'horizon radio jusqu'à ~1500 ft. Le point détecté est donc + // « le plus bas capté », pas le point de décollage réel — l'écart croît + // avec la distance. + case 'departures_offfield': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-7 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-7 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 10080))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $lim = max(10, min(2000, (int)($_GET['max'] ?? 500))); + + // Filtre sur les sources. La restriction s'applique EN ENTRÉE, dans + // la CTE pts : la segmentation et la détection travaillent ensuite + // sur la trace fusionnée des récepteurs retenus. Deux dongles cochés + // donnent donc UN départ, pas deux — les trous de couverture de + // l'un étant comblés par l'autre, la détection gagne en fiabilité. + $rx = rx_filter($_GET['receivers'] ?? ''); + + $sql = " + WITH pts AS ( + SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc, + operator_name, operator_type, country, receiver_id, + ts, altitude, vrate, speed, track, lat, lon, geom, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + -- Bornes temporelles du segment : elles servent à aller + -- rechercher le profil d'altitude complet, hors plafond. + segb AS ( + SELECT *, + MIN(ts) OVER (PARTITION BY icao, seg_id) AS seg_t0, + MAX(ts) OVER (PARTITION BY icao, seg_id) AS seg_t1 + FROM seg + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM segb + ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + -- Récepteurs ayant contribué au segment retenu. Le point le plus + -- bas ne vient que d'UNE source ; cette agrégation dit lesquelles + -- ont vu la montée dans son ensemble. + srcs AS ( + SELECT icao, seg_id, + string_agg(DISTINCT COALESCE(receiver_id::text, 'null'), ',') + AS sources + FROM seg + GROUP BY icao, seg_id + ), + -- Départs retenus. Le filtrage est remonté ici pour que le + -- calcul de profil qui suit ne porte que sur les survivants : + -- c'est une recherche indexée par appareil, on n'en veut pas + -- une par segment écarté. + hors AS ( + SELECT b.* FROM bas b + WHERE b.vrate >= ? + AND NOT EXISTS ( + SELECT 1 FROM {$schema}.airports a + WHERE a.actif + AND ST_DWithin(b.geom::geography, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ) + ) + SELECT h.icao, h.callsign, h.aircraft_type, h.aircraft_desc, h.aircraft_wtc, + h.operator_name, h.operator_type, h.country, + to_char(h.ts AT TIME ZONE 'Europe/Paris', + 'DD/MM/YYYY HH24:MI:SS') AS ts_local, + h.ts, h.altitude, h.vrate, h.speed, h.track, + h.lat, h.lon, + h.receiver_id, + COALESCE(NULLIF(r.name, ''), 'Dongle ' || r.serial, + 'Source non identifiée') AS receiver_name, + s.sources, + (SELECT a.code FROM {$schema}.airports a + WHERE a.actif + ORDER BY h.geom::geography <-> a.geom LIMIT 1) AS aero_proche, + ROUND(((SELECT MIN(ST_Distance(h.geom::geography, a.geom)) + FROM {$schema}.airports a WHERE a.actif)/1000.0)::numeric, 1) + AS dist_aero_km, + -- Distance mesurée au récepteur qui a capté le point, et + -- non à une station unique : sur une base alimentée par + -- plusieurs contributeurs, une constante rendrait cet + -- indicateur de fiabilité faux. Repli sur les coordonnées + -- de config.php quand le récepteur n'est pas localisé. + ROUND((ST_Distance( + h.geom::geography, + ST_SetSRID(ST_MakePoint( + COALESCE(r.lon::double precision, ?), + COALESCE(r.lat::double precision, ?)),4326)::geography + )/1000.0)::numeric, 1) AS dist_station_km, + -- ── Profil d'altitude du contact ──────────────────── + p.n_pts_seg, p.alt_debut, p.alt_fin, + p.alt_min_seg, p.alt_max_seg, p.duree_min, + CASE + WHEN p.n_pts_seg < 3 OR p.duree_min < 1 THEN 'indetermine' + -- Creux ET bosse marqués : profil en dents de scie + WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000 + AND (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000 + THEN 'mixte' + -- Descend nettement sous ses deux extrémités puis + -- remonte : approche interrompue, touch-and-go, ou + -- posé suivi d'un nouveau départ. + WHEN (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000 + THEN 'creux' + -- Monte nettement au-dessus de ses deux extrémités + -- puis redescend : vol local complet dans le contact. + WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000 + THEN 'bosse' + WHEN p.alt_fin - p.alt_debut >= 1000 THEN 'montee' + WHEN p.alt_fin - p.alt_debut <= -1000 THEN 'descente' + ELSE 'palier' + END AS phase + FROM hors h + LEFT JOIN srcs s ON s.icao = h.icao AND s.seg_id = h.seg_id + LEFT JOIN {$schema}.receivers r ON r.id = h.receiver_id + -- Profil recalculé SANS le plafond d'altitude : la CTE pts est + -- bornée à alt_max (4 000 ft par défaut), ce qui tronque toute + -- montée qui le dépasse et ferait conclure à un palier. La + -- fenêtre est élargie de la durée du trou de segmentation, afin + -- de récupérer la suite du contact au-delà du plafond. + LEFT JOIN LATERAL ( + SELECT count(*) AS n_pts_seg, + MIN(q.altitude) AS alt_min_seg, + MAX(q.altitude) AS alt_max_seg, + (array_agg(q.altitude ORDER BY q.ts ASC))[1] AS alt_debut, + (array_agg(q.altitude ORDER BY q.ts DESC))[1] AS alt_fin, + ROUND((EXTRACT(EPOCH FROM (MAX(q.ts) - MIN(q.ts))) + / 60.0)::numeric, 1) AS duree_min + FROM {$schema}.positions q + WHERE q.icao = h.icao + AND q.altitude IS NOT NULL + AND q.ts >= h.seg_t0 - (? || ' minutes')::interval + AND q.ts <= h.seg_t1 + (? || ' minutes')::interval + ) p ON true + ORDER BY h.ts DESC + LIMIT ? + "; + + // L'ordre des paramètres suit l'ordre d'apparition des ? dans le SQL : + // les paramètres du filtre de sources s'insèrent donc APRÈS alt_max + // et AVANT gap_min. + $rows = q($sql, array_merge( + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + ], + $rx['params'], + [ + $gap_min, // seg + $vrate, $margin, // hors + STATION_LON, STATION_LAT, // distance station + $gap_min, $gap_min, // fenêtre élargie du profil + $lim, + ] + )); + + echo json_encode([ + 'departures' => $rows, + 'count' => count($rows), + 'from' => $from_dt->format('Y-m-d H:i:sP'), + 'to' => $to_dt->format('Y-m-d H:i:sP'), + 'params' => [ + 'alt_max' => $alt_max, 'vrate' => $vrate, + 'gap' => $gap_min, 'margin' => $margin, 'max' => $lim, + 'receivers' => $rx['ids'], + 'receivers_null' => $rx['null'], + 'receivers_all' => ($rx['sql'] === ''), + ], + 'truncated' => count($rows) >= $lim, + ]); + break; + + // ── Agrégation spatiale des départs hors aérodromes ────────────────── + // Même détection, mais regroupée en cellules pour faire ressortir les + // ZONES récurrentes plutôt que les événements isolés. La taille de + // cellule est donnée en km et convertie en degrés (approximation + // suffisante à la latitude de Paris : 1° lat ≈ 111 km, + // 1° lon ≈ 111 × cos(48.8) ≈ 73 km). + case 'departures_offfield_zones': + $tz = new DateTimeZone('Europe/Paris'); + try { + $from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz); + } catch (Exception $e) { + $from_dt = new DateTime('-30 days', $tz); + } + $win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200))); + $to_dt = (clone $from_dt)->modify("+{$win_min} minutes"); + + $alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000))); + $vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300))); + $gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10))); + $margin = max(0, min(50, (float)($_GET['margin'] ?? 0))); + $cell_km = max(0.5, min(20, (float)($_GET['cell'] ?? 2))); + $min_evt = max(1, min(100, (int)($_GET['min_events'] ?? 2))); + + $cell_lat = $cell_km / 111.0; + $cell_lon = $cell_km / 73.0; + + // Même sémantique que l'onglet liste : restriction en entrée, puis + // segmentation sur la trace fusionnée des récepteurs retenus. + $rx = rx_filter($_GET['receivers'] ?? ''); + + $sql = " + WITH pts AS ( + SELECT icao, callsign, aircraft_type, operator_type, + ts, altitude, vrate, lat, lon, geom, + LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev + FROM {$schema}.positions + WHERE ts >= ? AND ts < ? + AND altitude IS NOT NULL + AND altitude BETWEEN 0 AND ? + AND lat IS NOT NULL AND lon IS NOT NULL + {$rx['sql']} + ), + seg AS ( + SELECT *, + SUM(CASE WHEN ts_prev IS NULL + OR ts - ts_prev > (? || ' minutes')::interval + THEN 1 ELSE 0 END) + OVER (PARTITION BY icao ORDER BY ts) AS seg_id + FROM pts + ), + bas AS ( + SELECT DISTINCT ON (icao, seg_id) * + FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC + ), + hors AS ( + SELECT * FROM bas b + WHERE b.vrate >= ? + AND NOT EXISTS ( + SELECT 1 FROM {$schema}.airports a + WHERE a.actif + AND ST_DWithin(b.geom::geography, a.geom, + (a.rayon_km + ?::double precision) * 1000) + ) + ), + cells AS ( + -- La cellule est calculée UNE fois ici : réutiliser les + -- mêmes paramètres liés dans le SELECT et le GROUP BY ne + -- fonctionne pas (PostgreSQL ne reconnaît pas $7 et $12 + -- comme la même expression, d'où « must appear in the + -- GROUP BY clause »). + -- + -- Les ::double precision sont indispensables : sans eux, + -- PostgreSQL infère integer depuis le contexte FLOOR() et + -- rejette une taille de cellule décimale (0.018 en degrés). + SELECT icao, callsign, ts, altitude, vrate, + FLOOR(lat / ?::double precision) AS cy, + FLOOR(lon / ?::double precision) AS cx + FROM hors + ) + SELECT ROUND((cy * ?::double precision + ?::double precision / 2)::numeric, 5) AS lat, + ROUND((cx * ?::double precision + ?::double precision / 2)::numeric, 5) AS lon, + count(*) AS evenements, + count(DISTINCT icao) AS appareils, + ROUND(AVG(altitude)::numeric) AS alt_moy, + MIN(altitude) AS alt_min, + ROUND(AVG(vrate)::numeric) AS vrate_moy, + to_char(MIN(ts) AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS premier, + to_char(MAX(ts) AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS dernier, + string_agg(DISTINCT coalesce(callsign,'—'), ', ' + ORDER BY coalesce(callsign,'—')) AS callsigns + FROM cells + GROUP BY cy, cx + HAVING count(*) >= ? + ORDER BY count(*) DESC + LIMIT 500 + "; + + $rows = q($sql, array_merge( + [ + $from_dt->format('Y-m-d H:i:sP'), + $to_dt->format('Y-m-d H:i:sP'), + $alt_max, + ], + $rx['params'], + [ + $gap_min, + $vrate, + $margin, + $cell_lat, $cell_lon, + $cell_lat, $cell_lat, + $cell_lon, $cell_lon, + $min_evt, + ] + )); + + echo json_encode([ + 'zones' => $rows, + 'count' => count($rows), + 'from' => $from_dt->format('Y-m-d H:i:sP'), + 'to' => $to_dt->format('Y-m-d H:i:sP'), + 'params' => [ + 'alt_max' => $alt_max, 'vrate' => $vrate, 'gap' => $gap_min, + 'margin' => $margin, 'cell' => $cell_km, 'min_events' => $min_evt, + 'receivers' => $rx['ids'], + 'receivers_null' => $rx['null'], + 'receivers_all' => ($rx['sql'] === ''), + ], + ]); + break; + + default: + http_response_code(400); + echo json_encode(['error' => 'Action inconnue']); + } +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/webapp/src/assets/flags/ad.png b/webapp/src/assets/flags/ad.png new file mode 100644 index 0000000000000000000000000000000000000000..13150dcd0064634c8127cfd9840c73b7685825f8 GIT binary patch literal 644 zcmV-~0(Z)02}AVAmqpZ^wS0O&39#{dLaCF(Z2uy03>u$PE!EGzu3a8#l+ZX9mT&3uOOjoo#cBjRc8Kst+D7;=%LCR zl{dy32lZOWmE$ z8Ne3%8%G5%$e>{h03%s5XyEN4KkfElL{v0N(G7DPPCj-Rv4}JZPSf$z*_lg5+8_oB zRXhI?NL)S-hAzyxGErATjvqwfF&S^m2GnG6@t*Y7-^J4H@gR!?7zjsfjg9! zOUJHADS!lUGTJk(O!VcM=*u^o&PGF(3V)V<5hkvc0t}LN|Ch*lp@tp^NXL}mLi91S zCV⪙OJ&*090?X#m4yfIGvrHbaizxG&ID>$OxmOqvZ2>#>U3TX0uJji^U={Gc!z2 zPbW>r>U(emoSU0F(_t=`V{mYgo}L~unG68y>+38oE)qpi?Oa$BZ2+8qM^O|t2sMji ziIzUw`Tq9hes~b&A&P*_?oq#;wsq4~75WH#n)+0cF1F|?B5;x>z#jY%$n5Xi3`VZ_Nchaqm RwiN&X002ovPDHLkV1ll5_nrU% literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/al.png b/webapp/src/assets/flags/al.png new file mode 100644 index 0000000000000000000000000000000000000000..9dbb8190527be6f18043fff988bda10ef65deb1d GIT binary patch literal 551 zcmW-dTS(Jk7{;IPuTI_e*Hut5DJsYg7rG{NVWY#C+G32cDJh|KfmlI04y09g4k znVNb5jg2`ORQ2N3F=}%vMOArai{c%_%w=Wy8D^1?S>OfmmJmt+2oOT6^S~=YJS@Aw zFn&Tp3^NT-US?)K1A+`Q1-So{1_+P>e$zaV1ik{lfC%6N5`Y8v0fgu*&<}hBTtGMQ z9MAxZ09|DVY=8i$06Kub^itpfa0VCxih!p;CvY7&2}pn)K=ZA3st|Ull^TQ#CnsLJ zr{|lOZOhfXk~&}5w%uTueWIEV^!1}Gfaht}hywWZ7=d88A5GBm79Ql`=y@jH$Cd+v7kjkw$u zTaS#7Tkpr5T~cXYBF|Ykaw_Rv8{Tcnaoek{AJ%f);sd+n{TGURc+N@P-ej!K_2;rG z=_yG|Z|4idB6difYLEI)@HwIyt64Nu+_%%T!tp4tZt8-3Te`l@{^ literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/am.png b/webapp/src/assets/flags/am.png new file mode 100644 index 0000000000000000000000000000000000000000..8e06214998009e8ce297dd9090d5175abbd9fdf5 GIT binary patch literal 601 zcmV-f0;c_mP)0I_cXxdIrv0~om% zF2DmGw{-x!d;r6S0itvO!G!<-EtuN?6x;wA05F~30384`px6Kr0XL!oJEQ<9lJckk z@T33$Kd14b0PmLo@S6b6029Xm74DM&@SXs^02$bb0{}LS)Bqg-A8^Ks0I_QTl@dak zbuhI65UT(Tz2`hvW zN4<|DATNY$XjsLA0MUd1s|`0xQ)_n#DUDwiR!vj9asVA28wdaZ02y>rPE!EBBd;U6 z)yCCt=C9_wa-W@53!zoY$qQ!g`zqxC008+(L_t&-83n!!9u5>*I$q4%3CL9&-R)9WIEJ?OWhq6r zcOu@kk3c~V0(BhL>7juFPQRgp1PWB31Buu1m#h6Z&;h&nu=$#pskf)njr}-G!>JtN z{fL=LLBT@5Ntme$3KsM2B*He&FWu`upd2 n)%+~L?&0EJ96Cyyf4$lluDl)YQ~|t<`g#!)>3T|N#KpzMwHud~0000NbW%=J0KvYWVCL1xue#XDa|?1CC+M9oRUn~- z@ACuz009L_L_t&-83n;XdeT4`0MIw-pQK7cTMP2J)I+%N{wL5y_tu3KR4A4vlfd}i zm34J~Th~pL#pNzCaC5HX%|{aq{jBrtR2Aj&L6tuLtjGk7pBvxqo-m{~41BMJr{ixL-US4(!-;rP@!;?@QmsWf9x*3|J+X9fo0R zc^h5NZ%0EG4C@6;UoT<7lj1+aYo80#NLMf;NG*Gxp89dAx` zipQjAB^k5x*jbzq6m60*qeF&}uWu7L{G&tWQxWRw>)aZ}7e?2OJhZ<&r1rcx0R2_u f*a!1!sP_K>mjZBxuj4Ew00000NkvXXu0mjfF#R`d literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/at.png b/webapp/src/assets/flags/at.png new file mode 100644 index 0000000000000000000000000000000000000000..10844d868e1182ed60410de1938691c8015c7035 GIT binary patch literal 613 zcmV-r0-F7aP)SH6E(&TE5r^ey9y%85i!jcInWzI z!3`(R7(KcVEwBnA`S|$A8bGND9h?asqzxyy2_Li>Jc$Pu(L-3>Y<|HPI;|5i_4W0k z3nAE0Xwf!O)kt8|)6?Xerj!XA?XkPiElJZ`Y{n-(xfM40=*v7>aDrnF0xr=*_V7kG!1lKQ= zkre=#s1-FuuOT7h3m(@sq-IDWmyH7yY=Gvt1i*$y^UL844UV8Tn#iF%H|!WdgBr9a z8+i}!(Rdfqi9X5~bDjXQahbh@uII!ismS&7lD|0}sEYri4tRU$fM!&!&G6Mm&8X>w#44t0ta400000NkvXXu0mjf%M%e` literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/au.png b/webapp/src/assets/flags/au.png new file mode 100644 index 0000000000000000000000000000000000000000..b4bb251d379c187ec33c22b09f9b6ca99b89af84 GIT binary patch literal 648 zcmV;30(bq1P)`xXV*%ni)EKZGx*{ zKXZ19vD;{fk(smehM)A6tM77>=qp_7?(@9F*G5l;%v^z^ugT(@vdwdh_tD$q(cbo_ zy68-D#LL>tyvd$vle3q!>^fz&Qg@$%o5KJ#c3OkSRDp<*KWP8}02OpnPE!EYzR6=9 zpXSG}xN`psFI9Wl*TcKI<_oL<00AsXL_t&-83n=3ZrVT?hSBGp8IMiCaVZg%P*L{T zXP5iGhNzoXm9nTB2*!?$|K`g%A~RQ-UGw?Q%o+g5EQE*WkBk3#`S*C=oomK9H^z{$ z3orUOE^pOSeQTP;H`X*j#LpvczT2Mhm0GfS+PevPg~y@0zzKjJkXzN52%BNMMt_c7 z(Kmqj>x?LZja5C&oTvB>g2ckHZG(H=U26|->EsTg{DD#K6W)FA+grW5=Y2TbNL6kz zj=u&&&*=m`hy=EdpD0d?6~5@`=Z9=0VW3GW%9f}=58GXnt{4#{h7QAp5x_RChKLXg zMl=F~Tunnn2o{V8_$raw?Dpv;iN)9pzz34$`i~*}0!CVmm`59!B$Vs`3jiL+Zxyp9 i04UYv(`=xSjnfy;0+n;#XJ*R)0000HI%%UXYOEw&pe9?MCR?CKg1$w8 zy+?w+Pm9A)kH<1@uqEIo|aII!Z3KzKZVRe zeY#nof&b&5MmBnR{pf!5N-?|=1iph?w+Ekm}m8f=OoW#Ad zMOu=!udG!gR+>qV)6Ac@^HgAcq?*aLH+GJGp@|)rnq6|N<)53wzKp5ThnC!d{F zFQFjh=v$T-f&c&kGD$>1R2b7mk=K%fKomr;5wmU?T# z&)YU{&$e?AIjU{+5r%=+8AD)oU9Y1+Y~6;F$z=Gh(@~VVi_)F@fe6J9zmrIZQW?L` e=ksS$Y4`_%(m;i^*pajV0000_EZ1&~F;0003TNklp4lpV>i&0wqKvDfQUBr?;nmU(*3yO{7D%kG2$+Wl$(RRoBcJl zRgCC`dA+!=q}hwHqSNWS+1bm|1|BPoE5a#Mn00000NkvXX Hu0mjfE5azv literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ba.png b/webapp/src/assets/flags/ba.png new file mode 100644 index 0000000000000000000000000000000000000000..2708ec961de44813427b621a3a86a19a9d3a79d5 GIT binary patch literal 734 zcmeAS@N?(olHy`uVBq!ia0vp^5PV745()>sT+XV`es;#2}u1aTVj`bxa=qK};t7 zK3OXm#M1dKCWIzMlyq#FF?)4#&J+d%28Oc}7!)TnC{O)=j)6g80)u2DgIw?b3k(dh zoebKu8KhfT%@&1)g#J6k@c#_Mzmp6M`tun?GZ-{xGN?`ecZ`8St~xR{j=^XlgG3>N zSS^D<+=dxCI{vHQ9AslqYhlpoV$hq*U^F2hWrK=k_p@D0YA#D1VmBnGCth98z#vkr zzFD-+JkdyYD_6WtGvdJAUqY-<-1__OdrkIL4#iFJM2XbMD1i z)oQB$|8Mp8HUx%FWJ!=;Fyj+xh1E-r?>d#Rzvza3=CEt?p|Mv89y23Km`E?t96*CAD?-pyt-g|iW zS<0`efB(FFzw-9o6Gn$^Opi-f%dJn7u@x0IG%8QNt#3HnF|akqZ~dM5o~Gm|DLc;@E>o_)ho2Z_1AB+f-;)$NZuxM^am40CGI9y$GgcT~Mkh+Un|oV?_)xlg%oRIKUtulHY<%=jf(@J(R% i=1r!2?C16WGoI%!GTHX?VHq%H7(8A5T-G@yGywpakyb+h literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/bd.png b/webapp/src/assets/flags/bd.png new file mode 100644 index 0000000000000000000000000000000000000000..7936534dc29799254b4c0405c29537f445fdc45a GIT binary patch literal 692 zcmV;l0!#ggP)w2T|xk8O#o_7 z^(#UESUv!5Qvi8h0AWV}T0j6(I`uF|0CQOYTtWbFR`o4K0AED)GfML$Isj}@0CHCV zePaMuJ^)WQ07*0edtd-%NdQ$m09!x+O*Q~+Q2<3SIa5ObJSlfZLrGv%06;D8E=4w7 zOx7; z)c^nh9CT7nQve;+zOrb_=AX@Txt}k;!PiyU3srmQjN02C_q((!4dRKGayoIyT? zN)<|$(ZeyuR3QSZHOl4kQ7s5`82cZ-KN$=L+1BzD@*oHT5p=M8?!EQ*;pfY;D4lys zpRf?KjNsI>+iO&ZFlvHD{_i#f7Qx>g0Ei{`!J3nTJa-`$}HjNj?+V#F${J z>+upaLYS=KnKt9hJ&cxsFMM@(ebcI5Mo|>UVHn2IVzcZ*wNm*1JGY&aX1i4^7Q5|c a0sa6mvO~Syp5@~J0000UjgO+^x_86eTQF1{)^t{fw;93|AdF3`3o0002QFg(LAJGLYwv_>Glo)^epG`^n| z0002Qr5OMK08Bv&0002~+$sLrC0|ny{nsGtB`^NoFYhot?JGC#E;{TdGyT;c{n;Y@ z(;f0>G2S66@H9c=DK+dVHRUck^lUisVJqP!FxxRc?ae6EAS&U)B)=RaSWXh)#Vy%Y zCG^!S;Ac3?sTWpB3*;o5O#lD@D0EUzQvkoyz5V?D{r&!0ECsOrH^VlE%E^a@Pu1A0 z*iWvq2g!!JtoGOky9>_M%K!iX#z{m$R2Ufr!LdpMF#rJ2mt1OUT2L(Fpje#z2p50c zO&t6L2M0HC5QK8M=6dnH0T-JZ!|w9DpXDR$*R>0TN4T3;r&rgxg~K0BAV0t`(C(-8 z$n3pI0I>S9n;Ex5lK{|w5hMT_f|0l&0cZpeK?Fdc97P=xfPg~OK>|Rb7F|h*02C@+ zsR9u|p+c!3aROAboT@e<0;ptB)j$Fui_m}su&>Jj8X&>^xzU$zPcNPm@hy)6;kJCh iw)dNOn)=Vx{L4QBf<$Hxf{=9p0000!NbkZRY0Fh|aBoNLX4FHsE0iAaMm~a4%W%u{@1&3qSC=%Az)&PoP z0H1k|J|_T!Ubq(uza9;%J0l2|ZvbdY@$vDmAP(s0=*-N_#OL0v7z*v}?UWG*;PUNV zyrZKR3c(l(w&K=LY)q@)(pFnNo7BaFQZ^fVR<$Y@;Nak!69{_9v@ooU(d*RgbyVKq9KQIh#ZvV3F2Q`(@aD`mUD=W#+%>%5)AXPLiy2(GQtpNJzf-f zp;r_|mW@iX)0_srL)GtGQ=P6z3xYt4T?)PcX$mmHAKZ*#00000NkvXXu0mjfj(-7S literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/bh.png b/webapp/src/assets/flags/bh.png new file mode 100644 index 0000000000000000000000000000000000000000..3a3031bd632f3881b91cb871f3b635a38cee9ace GIT binary patch literal 662 zcmV;H0%`q;P)j*(Ms-B^lc*9nv2Z z)+89(DI3}-8`UEh(H#@jA{X2)Ak!cg`1ttTFe1z!71h<%-ZLbj7Y_XS`Og~?nG_7q zBpBW{C#D(^*x1<5&(G8$7RV?Z#u^d68W8aC@Zd!`tR55o{QR;R5bThN(mgTfp_}rz zv$!iB&n+Oi84(yFCqHl;pA>*=;-L;-rnToF%AC_}0?gq@c#yl868R03UQxPE!DT9iOkc*vG!Z-c`-!XdUPaon|l3 z*`aDLunV`qt;ni`-~a#t3`s;mR2b7m(N}wdFcg5{IKkb$TZBTfAQY;AS^)*z+wS%M z|3(v&&~y2I=Q+6u0G0A-E`M^IIY<%)&D!(4uR*j}j7ED*CY@$V(`gU1 z>KvM%(6$;P`Y_cF%u8`lkJTD|tY%!ZPD!|XXVa#)#<;=CfsU;dGrMJj zQYT(?Q$E6zkWC_8*PEqdsJDcWl)sRIb(ez2gI=t@ki%tw#JbC=TzIaUwD#KImv5At zhMb?V%qt%Mb^rhX8gx=lQvjdl#15%-U_y?rN(bwTXv>E5j9u7vAbC`vSgzi3!G-P*6F=0PeDB z+3hfE`F+B!ciwZ)64$|70H{2qomt(odQd%RJn*^e(O>kZxO|!{%HEgR>ysiEcLPS; z!{C>y?C;)88o{4Do*d1|fMCQbJ-f}9!0lgv%$r)g;g}fTFVbkMzxWuJV^&bs@r=7J zXqeRXdAQ=}`a`Y|0ecP_i0gOPVf@eTtIMUXuwJz@7+-B9rR}z!{&|Wp&4(DwMy18m z+te)&$$%Ui;0HQ?6$}V3=VU-IaA&<3<>3oBOJ6mEVBmo23G5F;BmoG90}8)Y`3VpJ zF~b1`Kc%ki0%D*M!yQz?vC~q{ON0bvyNy681_ZuC;BzgdjI{GTH_MMRx^qtVFcQ$QufwI;l- z>-?lBqH6wMH`|8_$EDNhOeV)`sG7QZ(1t0W*EfF;;FssA#V{kw*x9~V%t9!^_<@km z6T?vYI~@1G^H~+?rPR7jh;g~hWHBAIGwbu0_M<{&%okaX4mh^f&9&a)hL4SFdL5@)jl=c7s002ov JPDHLkV1nboDoFqU literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/bs.png b/webapp/src/assets/flags/bs.png new file mode 100644 index 0000000000000000000000000000000000000000..b1d9943bf9074801eda4e76961307640bd0501dd GIT binary patch literal 609 zcmV-n0-pVeP)ZLISeOQOaeVdmXbc+y8o5{000?uQchC0003HNkl;HexlB|WD$8+vO z1c1BSL7!#W{@}W|oPs~*voT;iO_DZ}WO}x)DyticqQ)!Basf;oAF)B;YJ|G3hjy3& z2w|%GM5ADunx=V%tp_+j7!luUhk#5 z1$a2F38EU&XDRdKT12#4J5ys9XQfUq(ga>2N0?9mfuJAn2oSqVZnw_MjjA##Tcnx+>+2N48W v7C26{W5E4v-u}*X*&9t5j^mig=nDJ-QY$x3N$D@!00000NkvXXu0mjfY^(Ve literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/by.png b/webapp/src/assets/flags/by.png new file mode 100644 index 0000000000000000000000000000000000000000..fa2f2e4bafcdab99e803cf6d9f896f603659fd98 GIT binary patch literal 621 zcmV-z0+RiSP)14e5*p7IAk!c#(Hthq6dckWDAgn{ z0CXwEdW>@nGF-Z86?OeE~F3_07@6> zqo)98A^>9`J5V9986n$UWy4KXCT1(fe}uwwc$H&QyI^I!Xl-veA0k{NB5W>1TPxy? zk*FmfQcx$1Ga?^A7}z#Iyjxe^V{6Ejn8%!-vNSxTe{QFXcqX4qtpET38+1}mQvl|2 zpT%9xw6)cry9+1B8|Hi2FICsOYaq#Hiak;Z00039Nkly2)3$W`kA3pUH7$&}~07~xZ z*ct`-Zv2vTbtY;k)FMBgmZVQJk%po|9(Jc*XOV_N1u-lyr4qG#N(-e&IdhMWDiY*L7EfNG;tYdU+PO} zB_vwWV}ka<=6noy-P&!o+}73M2OWsc;kFQU=A+Y}4(I&N;k?i3C|2gjL>-R;fEam! zw1jAe$kBr|!G_G>XQHrJQ6|g3r?AuXB!J81`aw}V&%2yXYed8h@lX`Uab~mGZnxWP zwvmw$mSsIOP1r$EvyqYGM&lR&2QUiY{7(h|BY?$XF_}zOt95E>3dtPAKLiB=C&9hF zFn|w$Cw5wz6<`x!8i4d!Szab<;O;Iu93cR|-;XORI6RCp8S?zj)>bGK!i5F5+l>r^ zc02a+8|~<>CzOBsVCR zipv<6X2#Rdb~ZM0@v9)%a9`hZ@}(&5m?!=NKlUrwjCV7{)lDqks>{4lcTN|Z9>4uv zDte))9LlMWPG?oM4^sUt7m_nx={1+R!8?y9-iTy{32?2eGJ9~2mbl+3W}4F>u&+zf zgb&w+QyAguX5y7ot>sMevL&&i>WPVZrtS)EfD{jGCnWc#w4UD-U!vAdkB;~E2wFCF zin60EPa_=6kzC(Cb)sYSb-UxxC+erNG)=0?yIq*|NURn2XFcqHUNjpTpI8gz)xO{5 q1LZ39;$C2BsN1c%*sQ(&QI@koOW54LZ}6pnTn=)XQaT{1X!{4A+$=Bv literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ch.png b/webapp/src/assets/flags/ch.png new file mode 100644 index 0000000000000000000000000000000000000000..d125ab0ddd4155ae58786f80cb5994e5304b8f81 GIT binary patch literal 436 zcmeAS@N?(olHy`uVBq!ia0vp^5E0cir0JzRItO-#W6(Ua_PnEe9a0HtO*5M*JZgou(GcxE#3G3bf{~R;oF^z zp9DWW2dbU-G<#W^gEmVsQ^kz%>pq%_OBMvyT;e%ykeqjZgZITrWsjP+Y~=b|9HEz^ zaPZAX_JUArZ8es}wxj~^Ht XJLFO{cDG*vx{JZn)z4*}Q$iB}V&b%y literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ci.png b/webapp/src/assets/flags/ci.png new file mode 100644 index 0000000000000000000000000000000000000000..9e306e184105e620ae46fc623060c62db1c65a7f GIT binary patch literal 636 zcmV-?0)zdDP)8K?GZ^Y*YwI5dRek zArjn8_RYL|EOushW_L{@zUB>&_s+TJoO{Ose14o=3*N6IcT;A|CbxhQDVy)j+TCdH*S{mW979 z@;t|TPru*Cdk-K1eV3=g&nL(rCTGySbi_Av*ab47ygSw)BB^b*go|xcz4t9zzstD>+TqhXLK^1a? z-~{-pJ&)_6>f9R~_Mu-u5e8>bVVv37AxVBMG` z`z0eN*Icr-H4j{sbkRBIk|ZI^GO{cqj|>iu%KQup17AQdhofAWy@@6F-UmH9^t@Q> z@MGvL0Q>OtHUOPY=S;iZz7axL>2|v-l9qc#{|dXm7cu6L0;$n-vZMLztZw4w@7V0zQ(b3V<)6>w<(8|in(9qDF6%EzZ z)fGvYof{C-)6>+{)QcAv($dlhNS6aamI6bW&(F`#&(F`#&;S4b)FBquBNy2w8rmrv z(;pSrB^l2g644$M+%6#7D;)qosJj{v0!Xa@Jf`;c_Qx9$su>UgL#hBmq{1N;nG+1% zGbI#Ft)LbT)F~XxAQiGB7wqip%gf8QDjWbgkjBQw06vuyP_pRg=m0{Q)6>)KoR$?z zt?G}7hB8!TGhWs&A>?yx_wexY^!Ig{(yt&D`T6eLQ*E z0rNge^VuInGkRNp`f=!sSt;v7`Eu+0`oUrgAN%_ePGUQYo?p{<-+=D`a|1r*%gxh2 z0Dyt!V%a|f0H|>+F4elY1ptHt6#xLCzRz8nu4m=20q~*3JNPa9IG1a`U?p1#43g zcVGaAY_t&~{Ld5e!4Se1Ey0)zpb8iA#uET$PUf==yO9aSpAP527R;p%)h{C3P96wf zNzbbh@SqvoNh9H6AR6=E=Kufz9duGoQviFvXtJHy!m`!qFU7@ZzoE|{RaN8*aDXFH-~SyD;sLmT1dgx} z)MaI-4l|AGWmtC20i*@(FGzSbyHNk@n1n zW5BT<{<66y%=_5@DzNFLw0l)hVUI=f>=cj)(%>%3jQPZc?Kiz)^UjONSMb5PQ c$IEa30jlO%2`!!To&W#<07*qoM6N<$f*rg4(*OVf literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/cn.png b/webapp/src/assets/flags/cn.png new file mode 100644 index 0000000000000000000000000000000000000000..a8aee61b64af9a9c2c69cd4596807cac3b12f907 GIT binary patch literal 653 zcmV;80&@L{P)tYN5$qNo?i(cU9VYD=A?z3+>J%IAA1Un`BIyzt z?i?lXBQ3QM7VjY|?iwTTB`@zHEaDX$y%QSoD>1DM67VM~#vLc{Co$U`CEpPk!WSR% zNG#SB9pe}v)({uP5g6erHS8}q&L}kLBrfJ3Daj!#&Jh>tC^YgrFX$N})*~+IAuIa3 z5XvMk=Nu<PgEp9PltC_L3Cza~tzgBKV*c@j)y5$_)PU1L0C7{{8^=gCF>w8TqUh zrqS;i0000RbW%=J0Iwb9)xIwax53F~yPu!fdwa@NRp`pk!)t0c2`E7T009|EL_t&- z(^b*ea)LkgVfW2%9?e@9Q zIy^3w0U5Sl145GAqS=0~RtwMLjw%dO<$}Nl$#l969VZNZ--PS=HJ&KXamao*-;VFR zx)2TxRaHBhG>CBc`T7Wb)687L9ZBkSQMg-X@0&Lhx-W)-S;O9qbQD6g2SVe?B|Jbb zTRK=Q(Hb~>ejr=0%d&{W%tSv`-N*gsvPeR43uWp!c(cO)v8aquSR3?~f{82R5%A{f zemP~Ok3uk~r_(Kk7vWyp-9i{S33+sb$Fh{2_hS?So2xQRExi<;CcR{dBuW1sM0<*gWeSA00000NkvXXu0mjfB5)LE literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/co.png b/webapp/src/assets/flags/co.png new file mode 100644 index 0000000000000000000000000000000000000000..8e0d867695543d1b232e3d69d738a9f772e5f2c0 GIT binary patch literal 569 zcmV-90>=G`P)bN|i&04#g>&H%CuCI8F-*tP`by#W8q0GkONbazEbA79k40Px5FiUt<{ z#sD`TU0)njW|p#U90bY5st#+v{D8(B$GO`D1_goQ(+m@+OXaF`W7i-9>3DS3Ss zL^%>Wgb+6{eZYqR000+sQchCB1s5ccKKg^`{XJBqQEPI!Ii=b z*WxX7iNXk!AVjZiC=7!%y%{k)!`xNX8w8%Js!wf>;}{;^!w_kx8>l;mVa(^t<$C=> zh70ao>xYZ;);07FsrR4h&^hL_=VS=>ySRyW`wI1(=hX({`kAEb`eVIm;?M0iZff@f zR#aOjtt`uOT{`?>>~zreLsFJWa`>W*-Tj8*_|WGcPi0aRMN+JOcAdLegn((Wp%N`W>(a!qg;sOkBGT0A>vj_-bp>}wXFEs*WEKDX?MF%! zy+bR00003jNkl~#FRfBr3pUY2m~_*{(Ztce*vZ7z#l^P_ zZ9ou}A}xK;`*}|2?WB1wUp8l{5p$q3FQWMv{H3GqLzX3}IuP+R(6Iair9}H2hq=2C z;th(!Mp`tx?@EhK*{)s2tIuly(5E?3407x*x3rGlf(u|AuhZqZ=jluRXYJlL7Mpr> zE_Q~;MZEYGM(bhvw807$Vz9|Dns%G@C#+Uf6%oi8O;xuktx!RLtTdIr|2sRQsvv;e zus6xd#r2}!O8IdWpxty(Shp6qvLorRc+pN5UE!6CJuO1yhyOq_td9k}&bff1#f&XuI T8^?2e00000NkvXXu0mjfciB1J literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/cu.png b/webapp/src/assets/flags/cu.png new file mode 100644 index 0000000000000000000000000000000000000000..78f2c5eecbb63eed379e450d6d8226e830c1ce7e GIT binary patch literal 720 zcmV;>0x$iEP)02W&WDsc@XX$2;112ch8GGqcW zf&ds}0yTyMJBtA&bpRV?0W5q?8$|#hZvX%P040ab7Z(61jQ}Ep(IFuK8fE|?e9s*n z1UZuc8gE~Dw*fYpvmPJ-FqD0dzyKY30x5>A85wbfyOgER06d)lE03lV6Sx;303dPU zaB%wh`{w5FEJ~XG{r&(GSIHY2@a^)`CMMilTeia9&(Y(;$=?7ifb{hB05Xeyo6W`+ z7FS}RH(RL}D}_sKv`j2()7k6OKR?{z@3X$#<%x;&$H%ZJH^|7y_4V}uI*cDBc^yBO z*-cIO-`~K%z^SaY>YAEqiNn9YzjAP+8#IMvbDN^4wMcd2eE|Rf0X0cPK~xwS1;JfwQegl9;O9N> zd(7odek@H{+H5xxb+Mw01>N<(gJ2Osw1~RtVzE!RP+E=VbRK)2s93a>*@j})m=`xd zT_rT%_zbp`e7{_f7X#9R<;^-=A0c}>S-?fv4Wn7VU7XULlm{79nW(%DqZ$22Z(-2B z{JprP8k(^YMl-}#H~|2=t4uVArjH+P0)TqT7yXUv>d(E}Fl!x$M)51x3O|m|!?UgR zcP~Go(-}{oB#NtKIv!5Z>3)S0k+pvr&gu+{)NDN2EfML(SBbHGFU}7!vDy6-)Gm%j ztCZJ>J0AfjCxDdDx&N&u<&9Z(2pE)kqO<)$iAgN_^@Bv}_4aPU^RWHC0xXHF!MvU9 z?3ZUtR;{}kkvX?LUqWwMy#(%EHLvp3lI5L6U3M!NIw?xw*Ny|NsB@_xJhv_|(+Y)_4HeegW5d0PpYa z>gwtC_V(rFY-~jGz_lN-u z1}Gp@*|-(E*Z==BWu!dVbCcwIb8&J408_iXs8r6X=e2gX2l8U-ET{&tffWTy#Gdhd zgKagNR}GhUK%l~+WRzSm3`s$gIS3H^S6_Pw%V4gvWoK<+20R6MEv{a-n-6i9o>VOd z(>pFLQJjv)Zze1QT8ddL2vfg+QM!4`W8v9)IR(>7V`1gdPSghqvh43SzE`xIz+(nj zhhbQHO9ALpm-Fa7n}oWc02Mv5lSPX3@D!l@b8x?-gRVdd?g(Ln5F+6TiS57n-~@Q5 z-{Ls#rmtxZAKQi`k=cB457;_Gf2Y@N-wp?z>&{?!$ADkfA2?_cEGKdR0000sf)Pi6)DjmIOM@9yi_Q)c5k-C-SdIAk`OMAD$qW$03=#10@sFVqvU6JnY?%Utv)6>%rI%pP2 zcI}~~<7a9(I%#MwP|g$_q6`p`BRU&Xf65RQ#nRuV9x&^Zmgs|qp|;G~B`ZX5oZd!C zDL!%Nhl#Po*48vQuwP;B)YP#YE7{rE5=VZ4C`5N2F|Uc5CjbBd9&}PpQvjdSU7x-u z)xL7C-xJDcydAH5FIDL13%AOD9SM$w@rbg)+f zgcM>X4NU=JssajP@BRHB!kMVZivQ(4-C~CexUgRBUf-3OlSM})LAQH z?23WHr?pe5)he5-lZj>7wqY2yrFTiNo2vf&&iZmHmmmp(AQQPN_yT9!Ls3r=a!~*P N002ovPDHLkV1gc|IAH() literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/de.png b/webapp/src/assets/flags/de.png new file mode 100644 index 0000000000000000000000000000000000000000..b10182294eb1a6b0d02a386f1d4eebb40a1b9df0 GIT binary patch literal 576 zcmV-G0>AxO<*=(kAbhi9?+W-EWk2gmVu|h6CmW|fgG<# zKpWTsw#iPn3w+PB23!CZa_|b!2V@;|9tci>7yklvJd1S%Yyid<8cu_|z-0moSPExw zRF&nlqg7MVGic?7{-s4Re2&t|iIus9!VD_`!M+i8fWkP%TT!vK?4R6>~4Sw?m6aiK?`-Ik)_iY zo26PX&IXsEWqy>IuV597=1p+|!wAMPOeR@_0LF*FhjW!IkM5Sgh{SO|fm&#FAWqOU zr3ChiHSLXyB>1cyJNKR~Tz-JtSuG}hON`MDYHq!CMlAxo+(qTE!ts+PJcsVWC*X(tnxAa|B*wgD0$xEem}tGD#d)#8($ z^3K%WXMWnUxZl;+g`JQu0000dbW%=J0KRv$vKq9rw6t*7{Klr&SS{hSnE7YkrO2lT z!-9gew7>`6{Is-RZEo%W009?CL_t&-83n;nYtv8|0MPR_x1n3wvdtn@5Zvg)9tD4Z z|Nlu)@NEjyx~^>Pq$6q5+{SZ);cyzY$*I`I`4SBC)l+g_eOY22bw{@@dcx8k0sRWB~~ZNlPbWLWRWZZ4>Ke zTJLZAJA&$>93PVE{a2{nGe6E=T%7$IT|Fn0{(91{#p?Q(m1iFL9FqR9`KyakyNKhc zeloj!eY}$_Z}**mpHw;z4`o5(*oh1(2fb1*iaanRvOwjaSIUqOS-?S0DU(RFr#I7` z5Bs`DDFcai{9{}CGl;izBXr-)@55|U9Kp@DpT6`bgBWd({sXagdVy!9^~eAK002ov JPDHLkV1k;8J3{~f literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/do.png b/webapp/src/assets/flags/do.png new file mode 100644 index 0000000000000000000000000000000000000000..4b3acbc10fdbf07c1539e6071017e95d081c9a04 GIT binary patch literal 618 zcmV-w0+s!VP)7&^?%%*n~gdWe|_KyUyR zH2@GW02w^Rijn{tJkin7wtE;($m%a{r#P*zBN~V06c0S zM{xibJKI!a)Z5)SV~o}}O6|kQ02MZ)w#ns$kN5ZY1u0q5EI#%1^}4>oqQIewp|zs4 z$A65U<)NvwGee^*HWw{Twbs3hnwx}%h|{Bdj67009O`L_t&-83n;hYZFlz0MPT@xoz_z zw1GBS3NC!&!iDIM=>Ojup{*7sA&^)nB<5zW=Y*^6Xdl!;?qkpb<63imYl)kc4=?-Q zgXpR0PL6Lo)K24W;mfbT;M9y0YV9<7EKEu%$74@c=^A>lK_!t_b=)<241r2Qrkf1U zDN9h1@Fu=rz8qSZUyPU2(sayr7pFh@`0o*DzsCFLCs^@G0$`uZ1i(#J-W{6G>~MPc zoZ5K{^Lm!!{>cWFQRmgP>q+CsG|NBFHdG{dMzIdEEtLeI2$9b-DhZ$v0-4BDaND*D zAs{}=i$2t*US7L>P%=|LwlR$8fY-a4PQ!jJjk>G#e}W@!iEZlGg#Z8m07*qoM6N<$ Ef;co0FaQ7m literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/dz.png b/webapp/src/assets/flags/dz.png new file mode 100644 index 0000000000000000000000000000000000000000..25d46d233eda57b72d97f27f3983934fc9b0ae2f GIT binary patch literal 731 zcmV<10wn#3P)|#!A^=Jx07oLv&(8oxA;H1H zz`(%I&(FTTzP!A=|Ns90az6ldLjZ0&0B}A4Vlnmg^#Nxz0CPeBYdHXDHUL#A0A(@& zdPo3wMas*{(GoWRY(WBFFaTXG24_17TQ9-E!RYAd0BSb{WHEYs7|{`&j;`1$<( z{QUIxz8N*_?CkdT_I5cq@$vEaU6T$NTW|^w-jiP%$G_F^Dobp?W{J z0000ebW%=J0Ls&7ue=kxC$GDo9oN~zp=-~{X8xVzz~~EmFICF7=;Z!;RsPw-{`bX_ z0003hNklE3&5M@ndkP}+hW6p9QPGTd9={|)>LrE)IM`Erp2 zq1f`$;{3u&bYpjaZwF0Cg#svAz!?A+M9r|Q?LZ_PjvSv`c~rOpavYb*ux!O{)UJVs zeKQHJXIYFp%oI1|z{4D4?V~yyG%h!u^L1GszD-m50RrdF!T)L<`?c(adt|WSd0uoJ zqTAJ`?S)%p;ITL4FG5DcJH`(Jk9kqlC8tm6NbWYhP(q3bUXn;%{-({gf1-p`-Sw0z zednjbRBb;LbzLHq3PSPyv-{jG`@tj=uPAEq>2Z3&bPJi3fEE-Ls4z1jD+sEY9b^87 z8iefn=2kp@cyM&`lD7suc--C~-%6m>wK?y1=3-~3$wcrXc$o~P&>uDcP^^mt>J|V1 N002ovPDHLkV1nRWSgHU3 literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ee.png b/webapp/src/assets/flags/ee.png new file mode 100644 index 0000000000000000000000000000000000000000..79c3d226dac7cace654c7f5576bf60a45e2d17ce GIT binary patch literal 647 zcmV;20(kw2P)U3V%F4*d$jHdZzP`T7%F4dJzP`S`zrVi%Y_bDv zu>f7D1#PndU#S6Rt`~Ho0(ZCrdAY#Az`nk|qN1X{zP`M?yt%o#00030|Nj7Z&;Wna z0D{#3Zp#35&;WhY0EN~7an1m2zWDg~0DIB^Tc+gXu*-q_gK>gwvhzrXMA@AdWdyu7>u1O)&@eE?sg08*F$El+WA zaR3Vyv$M0PsHgyW#tj!B0BgtqbHkmTod9UCmzS3Sbhqj0>B-5-04GHNIAZ`|u`4Sp zPft$(I&c7Nv?C)UTU%TG{rwzV;&{{)ILSqp!U2(kbG002ovPDHLkV1me#FWvwE literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/eg.png b/webapp/src/assets/flags/eg.png new file mode 100644 index 0000000000000000000000000000000000000000..f82b65b59431256eb8ba493a0b15dbea34475691 GIT binary patch literal 660 zcmeAS@N?(olHy`uVBq!ia0vp^5JKysTDh`m}^WrMg5kg-NFIuVUU;(9=- zo$?lpU;t$Q7cc;k|Noy8P`xZ}a6wq>iloU^Y4eMsx<9}F*ej%dm|w}<-29}J>6437 zuFF}S7t**OYjK)ic@dA)O}B`vSFc{ae0igY=GU)ZcSxHA2M0e)E%Wm7l9iRc8<>1m z&nYP>>AHo_$Cnq>)zzm=nX<__;ODz%*LNknTE6Dap`u&+(~n3QKgh1wDrvZ1%IMIc zLq~x?L~A9loUX2}y}kYYn7k)Xp1irg?dR7QFK#TkcdY5%(VZ`Pr#Cm09@;*6!SwPk zU%tGYzwFqtWAAR<@_KP}7tk|`B|(0{3_A~}-WQ+dM=sQ9y|i(`m{Wa+^`??V9+4G+J|*w3=cRyNIj>1%$~HQ=J_kN@?~ zf*gT5#}#sSZPK-Q&N6Fpca5BDg0G^CkF@U6to3VH%QO!sUf;t|zV)Nsjw`=(s+H$m zU9lnH!GWfj4;&in*Kh|k9A-Myd{F&3^WB99EOj~(c51G^x>o;1K-jY zHw`Bke9l`oH%D>bx!FJXYh3Ob6Mw<&;$UulNRm( literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/es.png b/webapp/src/assets/flags/es.png new file mode 100644 index 0000000000000000000000000000000000000000..df14bde87ca7591256b6a729877e3feb050420bb GIT binary patch literal 675 zcmV;U0$lxxP)YM(#Q^)m0Q$B7`LF=_vjF?P0J$V7 zw;&|@!2tTZ0F4n8tso_b4G@hC4$q+g^u7VubPC^t2h?i}y@mnkmIt9AC2$D~^Sc1? zuK?YB3B!y9)}sTU6&ZgF4z_MR@Tvfp7#yS(7{^}=rb!l}8zAqf0KGdF%TgGxVH(|# z3%zR!wL1- zi#8j_o=}rKB$a4DxPqW`FFMGgFUt6TJpcdz6LeBeQvk8%${p3d)o8xHpH*`UpL;Ja zJn|%v0003ZNkl$|zhJg&A0B>8NH98(}K2DqVJ+4^nRUx;d5klj{d!)-CFRa1ed21S-BnWE>1G19`-hD zJrue;wP#LkX{vF91yFd1_J@?Q69-f|uwCj=-tU6|n49cp8YI{#xIdKk)UMryD#sE9 z3gVUkSlKwBMD(^+1gX7&83NedlxK0=Jg=T@=%1Ls5{y@_@_%^1bVmn&$q4`e002ov JPDHLkV1lTX6kh-U literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/et.png b/webapp/src/assets/flags/et.png new file mode 100644 index 0000000000000000000000000000000000000000..4bf8dad3433c07e53478931c8b81d1b003ddaaeb GIT binary patch literal 769 zcmV+c1OEJpP)I{MQEtgDnMVBDe|=NNR+o2@hVXDBw*G;64+;qXY_rIVNJ43Wq%li9Q}o zoK$ptE0ZWZnI%l9FyJ#64_KMRs09m;Ka)8~1#l)OQ0R7;bZq)r$Z1>QrQ#)v8H0r&UJVF z|5t;9i}&*Redp=`;9s|RZMHSHW!t(F1m^tKRp8aM4vu#^TBSryCR>|spU=J0>t%pO z#N%;-z%UHQ@hD0v6h$edk^vPxm^^qHM2(>+N>Uk6A{JhzX}Vo87$srAh?8bM-+ZRa zEIVvNRut+9oL(I48Jp7OX^j={8})i4fA@F{4oy$clJNP#ilam%5{b#F-;ffALbfJI zeVt>noI3h@!3x=t#8sgu|Anlm-_O{-&TU3M!NI=1zSq~+zP`S`zP{Jj*NmUA0y=8}IBEhn zXi;*J$;rvl(b3Y<(%ISB&(F`kzP`%J$^aowi;JQFAWQ%tO@E7=Photyxw-%U{{T63 z04inxJa_;$a{w)B05EO&`S}1SVgNI604!(#BwFd}>(tcL@9*!~+1UUlUtM{gp|i+m zh^+Vb_t4PLJzj~{+~wxx=JfRR&CSiq%E~`ukRe5QL~53#xz96DcV>U0zrVk{yu4d= zngB9nm!`XmoV4NL;m60v7)ye!z0fFFiy=^isJF`+KXd>!XOgSM03J#J9Y`rcWdIyT zrMl1l{{Es_k;MQ203vi!PE!C?!LPKxbINBN(;&Z}*V)gyoqI0}=%M7<a3j*)ti(BzqYihlB(_ZZ^-uP|bV%OVMUoe;bI-k8*0! xUdq;dMP*$HUW6~inw?HIHNLZ_i4=uc{{qWcYVQ?=`|AJz002ovPDHLkV1i9rLi7Lt literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/fr.png b/webapp/src/assets/flags/fr.png new file mode 100644 index 0000000000000000000000000000000000000000..63871f58be1808fee902beb92ae3dcdb90b0524b GIT binary patch literal 643 zcmV-}0(||6P)`kb!f)-#Pb=0o3n2yq^Elnu%NPzGUuAq%eQJarpk(lV89$ zci#yiY)aaUF>bA_taLcxdNIFzrPeRjT>Zyc<@TdD-A=p|obp#VrQSEto#Q4|Gv zp402~$n%^Kf;Kib+TQJb{dE7qE~Z1FOfg;n-B8iJ9E9}|S&V*#1S(`e&7g12PVAAPBq|>nl*|1XzL`<0)u`2KG%6Vv zEu=S&%G*St!9N+Egt3w6DF&05M1V0~aM=yzE|P;R#WHa&PFEi!KZT&A7>{Uj`WXpy0>SZ;kM1C5I|t=1 znmh+SB1odcZ_&s=GZ6T&gCj;$J8*|sfMQz0^9t>Dn`*T>{QiUxhT>)J(>lnB%KFQ#5DiIDXxGt>#D}7% dS5Mjh{2wmMU+T4yU>N`a002ovPDHLkV1l)KD8&E( literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ga.png b/webapp/src/assets/flags/ga.png new file mode 100644 index 0000000000000000000000000000000000000000..816cf34fc24983a52f2fdce04d5e9b327e2d394b GIT binary patch literal 709 zcmV;$0y_PPP)m40fkiolxH$xp#XSDC{~vOiBlj+i62Oa0+M6`l4JssWF19_A4Y})lVt&g zRVi1N(z6{QO^7R7nhA|vBTkMTLxKj5TLqYEB2SJ3i(N99Qqi#+(XkrPuNY!{do5j@ zI#zQuyaQx2{{LK^n(iZ@lUjU$G0G?nraKZi79WQ6PG;O~8 z%n~bNwg97M0H6!h5dfrWC|Y$0ah>N>xkUf~0UAj} zK~xyiP0?3Ff>0QTafk-EM`?Qx#6SVTz`+PLa__XgzW-O~Jshb17w6*lymyBni0oc0 z8i_<>$%9=2FMD|fZ;}!;t)dkKs?lLmtFY8^xdCBKtJP{8cLS@S+GtQhCkVb*%H=|# zSSFc}Jw;T@14RY6GG-_!K900000NkvXXu0mjfPLnx! literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/gb.png b/webapp/src/assets/flags/gb.png new file mode 100644 index 0000000000000000000000000000000000000000..736306b46a10d1f652037362b0c3b47d3fb35ac8 GIT binary patch literal 815 zcmV+~1JL}5P)_V1>X8Cdv^p#11OV6*$WiHOCJu|NsB-$jS9NveQ?!?Sv zbe;R{@AcBydycZpAwSx6hV}IJ@4?FDlcDap!mAT8(I-gJwYrv}z3#HTvkD>d%+uA_ z-orRhx}mqxH&Df%K7H$*Hmfs*4p~%?EUxnuN5=1z0#nm#NTCp zF;|nSm8;cXaUDK>zZx)AXqUOFtN;)`-E4tUU3>P}-sq^Y>7cCY?elbtvv7BrI7eh#G z=r8Cm(6iyJbo_U@7*~7-dWGg)YSBhXEv}p2NT}FB5Z9+QbOij#iM+4W!mreV>=69U8p5b z<48J_Ik`enHXn9r>Q(w0v^TwQbs#=)GD}e`&-3T?hJ~~caPrwns!&G9=PXUT!LgEM zq$%Sdn~BV64oNIcux4FVjr1_0Jb`&~e|W>%@t(*M1k3DYQCf3K)g}~mm7*w`Cg=h| z=(+t~EN0}aS`?0`$?gieNEeRdyHUvhpa1rspk^~hEY^#c^&pv?%y>;7L9tqL5~67B zRTz?FM5B_i_Yb8~3rwEZ#CSukn+%CF61pAOPD=<~f)Z|{yQDRYgc%%XUXD@rBGqn< zLmQU9V$k*4`)z#I|(9p_LQ_KMYx(yA!zP`S`zS!8<&(F`-*VozE z+1c6I%F4>p($drg1=ZEn#>U3J006hQx4F5w{{R60|NsABVE^sy_V)Jq`T74`T>f5O z_4W1d@9+Q2%=q~D#sC1*)6>bx$P3h_B>+9>` z-QE8L1pmIi|FyOM;NbuI`v2tQ|HZ}A0095@_x}(O|E;b3R#v~izt2rgyu7^s=H~z0 z-2c(h_H}jF*Vq5Dvj0Rx{|gKM@9+Qf^Z$s5|1K`yySx7r6aN+#>)+r1FE7*q0srXe z|9*bbuCB(SqW|mb!KtbLsj2YN(*Ky4;8RoC003-A4*LKA0338uPE!EYuN%Z?vemlE zdsPd+pTp!JYoX-mFP-QwAiEmv_Q?PM0V+vEK~xyib|IkyegBup0&>hEndSX9Gv^ZR9~|!b{rU3T$jH^z)zZ?^*VotA*Voa}(Zj>Ty}iA;5GcOBzPS-6xf3a_4I|mv z+1Jk&CR^LywlFkx3{;&yuJVb|KtoL<_#z66f^nx`Rf-q{`L0i>gw_F@!8qg zun8OV^z`z4i`)z%(b3W6<>m2ifa@tQ@{k((-x&0jA?Xn=^rsl+8zc6sAH)?g_xJbf z>+8?Y&%eLF%F4>==jiY6@Aa3Y^M#c8+Y`LJyzD|J_{kUK7dQU?{^uq^^ux-^Xm{7w z*Y?cO?qziK_4V_JmGq05;}tZ`&CS!()4~%j?pSN$;^OdWd-&EB>O3RrGAZp*Cib`? z;NITq95MF17VvE$x(OWYGEMMabNBT0?{k3il%Bd(Vca4$$H&LEUuf@l9PCvg@o_2c zO)<_DGsqP(@|+j$RA~3l7VcIh1kHDw0000QbW%=J0JIy}XR@cr)w|}+yL+EiFAHlR zg2{{~)@6duUwcQzYG{h13#c^S4mQ z1(lw~-YBX-ixw&svXzU3A$F$1fs)i2Px@Wv3Bxc{102WujQBN?KBaUz-N8^)g=X#I zlB`w*`U5-K$Jk!?QF%x29-vUn&Q__ABHN|X^-Xd26!sQ#RhZ9JAh8VSpTLgae;}hk zfafTRBDe@EvUA`wP;b=Nd?u60z7-Bnr^9oOAgG;W#sP>?Kl>f8$L(}E>;}WS%eV>O X#%@J8Ht@J^00000NkvXXu0mjfN6C(8 literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/gh.png b/webapp/src/assets/flags/gh.png new file mode 100644 index 0000000000000000000000000000000000000000..6a475a2b79888ee6c6f0a7b7e06be8a9d80129fc GIT binary patch literal 633 zcmV-<0*3vGP)pTQkuWEz}q@&k-j8XgvJN5&hL209Y~l&=>$?H~?QZ z%nu{h95?`MK>$xGzM2gHR4x3-6vY`a&Kx(s5+~L$C(^PS^1=|q4c4IZQn z8=r*@#HtjEatin0Yo?j z$QlMI0000NbW%=J0M&24$Fj#GzOvX|bD!oNp9|*CRWJS*UFBW?009X}L_t&-83n=1 zYSd5w0MK)9?&LKInX$SMx=;{YxGZS@{{!5K8xblMt5rMpag*pY@tk0I47t)A+I4M@ zH^6Y5LviLouo62yQyaOsHP<_h+w-D3LIM`PVRntC5n9 z!~`ub`^(PO`|4@j)ggp>7c2h(UN2f} T`L0|B00000NkvXXu0mjfS1s>n literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/gr.png b/webapp/src/assets/flags/gr.png new file mode 100644 index 0000000000000000000000000000000000000000..79a806ba963f541a44aa57c28c4f8189014081cd GIT binary patch literal 713 zcmV;)0yh1LP){0dH!Q;Ndd=JOC`vxcnF z3s|ZQRHhAEto{D}3|_DF_V^E3oicmBY@g1Hv)Rbp?l5$@21Ae3;_wDPgb`n*>+<-H zxZM-0o~!N0@Cw9e=2@AJIV>7~KnsmSC+dAOXs-ac`v4_%wE%H%nL zz+;)r$JgoyI(kEYz6wc+(bCg0Yn`vPyah9FrMSm`p1Cbmh9zOD*Wl`Ih^2dwsG_N| zd24q00000ObW%=J0KVqby44(P%CV(#FTo3;!`GcvRp_B0!V6In00044NklDy1vQf^5xQ9wGJu8 z5(fExUl?&D*>{|UqC8;vGG5EF>>m0Q)k8Ezsm4TKuSmyH6vYYjFb%ZTDnJ)x-m=`d zoZu)JOw!@0V69J#0fQvh@OOJSFi1wma5yxZ%|=Y8({8s~;t`%THJ;IIs)(_cy2kBH zbGM$ohp-^YOF)eW68dBCkpw#85=*C{(w-kJBVU^mHYg-LM&n7;^Jw#;Z;7Hv%s=5} z^e>S3@k|f|f9T2~9ITlZsOFB-^rgl8j__Y$epxYm92B=v`k z`LwsrC^Y@&=;|gh-6t{gWo!Dw#r)jf@j*!Se1ZGa)%c{S@>pK?ouTx0eD|20Y0GP* z0000DbW%=J0N>cjzRRDpZX;FZ3$f;LWdHyG`$Ix5~0TQohY23nucY z!D(tGj4Vt@uQWPM*o_Mlx%skwZ|DLswuMP@e2YdWAZ$$JYt z+f3d1uq5YrOva77zun88%SF#LKMEMsn?*t!4ZVG2M91C;+_K00000NkvXXu0mjfsxIWI literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/hr.png b/webapp/src/assets/flags/hr.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd76108139c3fcc03817b6d2721b293dc9a8928 GIT binary patch literal 658 zcmV;D0&V??P)YyUw({nOJC z5qbZ%w*QNZuuo$DB_;oGaQ|XrLp7ORUaweKuPiKPEY+mAxWcd1AuV?T7f~(KxPfO=x^SyB5a0hNCYITdXQvBCPed)ToKQS)BMKknO zLsD%0IU1UYFtruMQg;N)w-wLW5!44yS|!8PXgCwQVt*(}UVLggx(8wdLN-W}TuAoq zKtY|$8p!)8ib-KdK<{tbCC{wJqTnCJ|VxFZU@0M%_F>AJ}w<-npO|d s1OW%7G!gyIA(77H@x4?E&u0km0~r-No%VE~RsaA107*qoM6N<$f`{c2Z2$lO literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/hu.png b/webapp/src/assets/flags/hu.png new file mode 100644 index 0000000000000000000000000000000000000000..0c698dc85263c5c5eff10d1bc7064ab42230cf2e GIT binary patch literal 560 zcmV-00?+-4P)8r7@D>>+xpu^9`@3_6{?&`9` zwYV=nW`1ktR@dqP000$qQchC0002*Nkl2dyN`HWMj?`*02YXNa6r|S+}p04C^6%~k-ezL yZ4a%K3%dm6#Nzmr%c^e}T{kMnQiI=5yXFtCR9#yiM+R5`0000U2?qN2*m%DlY1xw*Oj00960|M>s_ z`1ttt007AV0M!5h=l}rq_4U{Q0O{%J(9qD+)6?YSi@>Z_uSn7DJj!u zXa9eH^&uhu*4D&nY24i0{r&yr<>lGg*(s~&jsO4vA#_qsQvkI7XuF@n%+=T(xy7%a zXdQc1FX*icgh?g3n__fL*s_x7SqjJAejQ~Qv7Fh=u~k3ij3LXsf!_WECigTWG%F# zp-YI3Nl0m23@(XW>ptCcGQ1}}$+EF~LFn!-z$-}yF~;Y%pXZ!kY;JB2xa9oy{k_}0JlqQQH^s&e-~C!CeMu1m-3}pk zA%HQRVu6*F6{@NtrNnSJB&9@*(R+J)M~sFq{qy!Q)~W0Q>qE?ED1BT+!mtuWmcHuT zTq1M}WMKJ^78V`cD6|=g_P22I(@gAKjgsH*xx2_a$LqKcMG=WqTNWx)Wz!zk2e*Q(}MaV})*ni-6RCIV^*rZez#lj@5XwP;?zjeyP(xXEB_Q3Pws9{xv*<_MY@(l}&K zhO24C_`>xG8f&HqCKlC00~!laH{xo7sjV*!St3X{2Hc=A3Hdr1XtqsFz%9Yp7PT#! zX~VTFI!AFnGHssNpe<6*G{g>qz zSSOgAyDaYS@81IM*j@=Cgt9E@_xtqweaet22Ne;pOqInTiIcO;+u_KPpB2b|1pB9O z#_sW>-PhCfC&2xOzW}hav$M3ew)T0>d1EjbY}j5Woqgit^pWwRO}2Dw`9S!05ubkj b#>U9V$j{Hu&CSiv&(GJ_*S@~K%*@QbzP{Jj*Sfm8$jHdi(b2xX zzQ4b}y}iA;xw*;7$^ZZV0XMe*DW~`N`P9_Z_4V}vJh}o!zqG^P05P!uG_(Lr!T>F; zft}o(vEuUd`snQUQ+v?8%|b zz{Sz-C}hg2wbK9`dL&D>o1(V>F_#Qf!2m?16jQ`*d!qnGr@zM9H_|NkXxAjWgNkN3_z%o%_irjSjivzY=@s#hQ;m7^gu&<6s+)Aa$* z*Rm8v(KVkx!r0t(yIoDw+8@D0l*XRt35)PBhp$8vo(U}i%d(w0yuabqQdO6dIN7xm z{m5=cFp|+9k)mu}Llh-dZuWrZVk<`+HqSE`Vd^;835{gG**^y)VI*S(ibqu5F^bJC&3LLx(*=MAU??-InEO)u?rfi5F*VOF|QUZ znF<)l7c9UNDWnY@kO>sD88V*?8@e7j&m1?t8ZXIdbkDn{!(Lv)HAB5XMbRNYye>4x zg^JFgrRnF|vUz2&PD$sy!mf#S$B%>0%ge;{Vq*XR01|XkPE!D+*qXh00002{NklqnkGg5<`C#!(BNC2+m78MW>#w|WDw?_v3>j=;?^A~WJWwa6I^>w_gcG0Q~)Eu zf%LN;Tp>dQymmM0-B?QQt`6Z$z^F8Lk)d`6gzkwl%x^%Xy9cb+MB!_EB1TkU1ZiS5 zQOM+Ry=o{h_SDsZOH;0na3r2Rw_x+(AG5lbK_AdtAbCx^CICDd|Ns93XEp+3G6GjB0$wiySuFo_EB|XE|9CG0YdZgKCIVY7 z|7RZoP9*_SC;wy}|8Xe)do%8HE&)j+%}^Kk`1tqr^w?}I^njrKHu<)6@TlPXB&AwY1#L%;?C+<^P&xI*VlGbr72uN z#>U3~zof@*N7dEqqJ^t(eyf46o%pbkr*w@|ag$|C$hQCh02p*qPE!DH)z!zoBgeA2 zpXR@xuL~XKduCNtFKSUf&;S4c0TM|>K~xwS1;M*+(@+2a&~u&Z2X>GHDiE?&DX_s* zAy)o@Z{-h=kjlowf|vjil#+sC`#P~XXJIwemfJ_)!Dg2EFyy&o zc}aMcmwO!(>h!{IjNQFcWUphQ#usOD3nDrbHHkBF3q-n2{?D>G{BTCj0x#oLNMC{L zHqM`){JDKN56^#2;q7h*JR6I4z5zUbnsoqUG5or_0+0cS_nA)sAp>v-MLuo;gf~@t zOb6}V#d>kte*#{YA-ubRo`CH7TVTHY*sQ{KqDMf6GMO~rEw9kZacOL%IB$fVOMTV?U8q^=H*p7q_r1`>jAmj zWX(Vdqz(W7{|}_ENSj`kFgzomd|p`dn3VB3LA47a+SlYPe}4aQQB3a;zv4bY)h}Pa znwy)Sm$f*>ue6XyYKOGRl`B_{9zEK5VDZo2KLZw(-+cWjI5_y4h4;hM(yLdm%F4=a z7uDV>X{fHQ{$k;(JAp}FUS20fwbzSiBqb%?^o%@o=umHO?+RYo``HzjFJG?MHtpEM z%NL*Dwzs!`^5n^Gw{Tru-QTa@O`A6DlD@+V2e11vx$kb=y6zbG_ut>Ex=#CVpYA!l z{Q0N1(W{&4cFp>F>Dt5h&ljHEHs|D~J$KGL{rGy_x^?f?Z`baRdEPs{XNvn9pg-kG zg8YIR=I`G9Z_|ydXO17r$ap3z`)qx6=-k8l)uH;_+zYp#&3erQ3_xa27sn8Z%gG53 z9#0cYO;1l36P!L_N>T%ZU7jCXnzC{z$A-57ZX3S2O|z~-&tbY$z6Emy8y4Gp<6W$M&Mp>Mx34k;8eceG5MytTBYn#V;#CvSo5 zIe{kNaL^U3>SpgqknEXtsG}dsx-f~bvNpODNtM}8D@+ZDx zpZuZG6$>@OCiKKu7j3*`~2tE)JnetR1!BHqJG3mjBhwaDuw#?Wdpe)QTEiJAb az{3y|*0PT>F@vEyVoJiBt)9{>}xZuLo)z$H#q0{Qv(**_c$jIFq z8fU$I`045O!opOuYw3G??TCnY!HVVa=p~Cl8-g@}%9H15X?(GYwtjbV`KNw(BfTP#CCSH2nelLS9+v`o(~TI5ULLV000*N5>~I*olV|;47DlTb+*dv|dLN1VNldHk&Ib z*J&h&WV6}JjwH#lESZuU+#MB!Wm!{3AxF~{g#MdZ(+T5Sy{l{hKN zPM^Ii3d1k>cs2NuAP6JgFdpt&HL=}p&EJh5_$2elg@Ysq0?+fpfKr>Fko%q7p|+er zQYynRs!Dk?MKLHSO6nZLut3VPETz#v|1=bjhi2~CCqM}Pu9cLGL$14n@YFlGi?k^nGf061_YVUV_LbPYOy0z`cPC|JBE zJVSk@23nDHm$J;2oW^i_VttyZ6eGKTh5=BU16G~^Q=9=tj{!-O0Xllw8ZQAui~?Dp z08EwvK#AWZJOg8=0X>EBrmyXYl}mxP16-2W89 zx`3|HbD6&Bev0Pn^7-4|Gis*4)8^ttSKi*|=xBLId!j&ivGT9Cs>R(#Z<)#&EZKR6 zI$ea}K2m|J&$=Qwwa(xoZ8595j02_2tPE!ECzn>iDV92r6U~;{u^b0Q_y4lxz&z=6tx7LX(6#xJMMM*?K zR2Ufr!Od>dKoo${@6KG?P1<1BDlI=Ms4FVU0)gNOcmbA(hlLklLs2CpRCcbKG)io+ zCxhKPoFi!N`#Sk4vp!EP0H_mn=ggrB7n}ATzB++q7a*@Phmns(d)jjor7D0J4ivC~ z95}g#f(?SzY!z&v1mFfD0#-AMGC)arx7LXWf>8&OILi5Z$qXPOf>8&?<9G6LzmevI z>gr{;=R?SsFZkO(9=~WQVNF9b`%O3>{bYyR@Sxs)lF!%qD$UNyZ_nE&R~-PrhveV* z#gv8k%X1hHju+wSzwFUb)v9(shzP*#9lO^~p2g+a_xVz9BM8uyF#1dWp!_VrAtGkL zJ|m=r;_*8ab4G0dBv9~pK&k0c|MqAtsR6} hL$;gU!_MvHoBzEDjYF730cZdK002ovPDHLkV1h4wQe6N5 literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/it.png b/webapp/src/assets/flags/it.png new file mode 100644 index 0000000000000000000000000000000000000000..03deafeba0cce176d39516e5b226b29dc529b754 GIT binary patch literal 493 zcmVB)4k5-Wu9-15^q$DoN%F4^h$;`~m$;rvOy1D^vGy{!P)z#IhB`={S zF9?rV0*_V!b~ylZH-``y#>U0~aXA2SHUMxn5p+NQ|NqPLomBvtQOG4Q z{Py+$pjrTvNdT5j0FOk@D>so99lRYTo*N{w8Y9@(*A<>x7NlVXhDZR6QobrR0DL_F zfkUPtDioMf0CqX&<>l}1@BaS&q)}L<0000TbW%=J0N7}Ir^VI6uKCJUFAJ}sy5t)m zYsqFH=o{p~w_6RO@&Et;%Sl8*R2Ufr!AnX5K@U3O!^6J5 zzQMu4*4Eb1&cfBz)z#J2)YR0{($d-4*|{J(yCOZ$kC(o8fxUi)iw6_Axw)TUXpRUI z|NsBa5-Ioh_s+9?E&C=?fr_6D-I$jR>R z?%is6>7%R6%ggBJ=jrL`(b3Vt3?06{zRMRY)z#J1F+}_C@YNnV_}ky^r?TQ?ciTKv z@V33#U?SdYMciy3>#msFP+{VT8QorOyjW+f5hI^DLg8|M<%E#!uC>)rU*(38+}zyB zATjd5$K;Qg=bM=D$hhj5qTaE%@WHS5+Kti3nAXdU`PnYo(WLX`m-W3G#!zL?L{Zjw zgVao2v`$*_>FUOflC%>c-}iMe0000abW%=J0Jn3m3(d#ozu2F@uh?eE8&xlR&;FfN zFX*BEyCDAlyJ{f*>|#7~0003~Nkle+WbN>KU@S#&9qIR}K+w62g;g);$Rk z96QPn2QF)X8k}=vI3VN58Rc(7^J(BBVH*NNp^hJpLtX{a_QX#HB)`xnOVe~JX0-9| z`$(t%`rx~_A(M+l0*TD)O?u90HkG1(`#YOJr`7YnBc7g`QK}>wjY_$-4gLWEu~YH` S%4pO80000c_F~I{T zA$Or%n$UC6H2p6HLhW;;-hq)>`rR~@)k%z9LO$m-aJF3TD2g&X_~K9}cE`uS%auW2 z(X`=OMviNb29;+>#p7^nG)?R8(RO!tpGl{a7x`AJ_2lQfJKR5_SS}lECmf2#Q^_+( cD*Awa044fAk-$FpBme*a07*qoM6N<$f?38J8vpi-cc00030|NqYsC(sir z0B11L6)pg5G5~Tm(-|_=8a4o2DFAOZ0BSMF6DX<>A^>GA_4V}ubT|NGE7Bf00Cqah z88fjJDgam~P?~c-k7U|KS2i>>%oZ)R88F4g#Q;kn9v&Y1{QUF9%KGK#)h_>4_2}CS*66nVG30Dko(_*4EZ41q+iEDSbIE08=D9 zMJg$|6fpn*03LKwPE!Epub;BHFU-{)*m7tK!o6L;dsUqs=*qjH{{H02a}XD-0003O zNkl40hQL$?h(=(&M;x3?~XlN#CY_X;H-pl>pKz50l$PC}n&iniv z0YEk$NJgXKU|?Y}lmNfW#=~HBn46m~9Q6B>KJo$w+Q)m&V+1t(IP`!W4P#t9@_;(V z2%!tR8Jr&f*~8!!l!~OkJk@HoN}(Y4Ov5nDUU}Rkz;T}y zCl_Q{6h%c4%2mmM3CZ-FcE#yc>5#xwrdNWX%+5(4>jb_xHd744tSpa4x-QY_U(NLl q*h)uce`2*UHMu#l^+O#>T{Py_i>FMk1>)F}a@9*!=&(HMq^yTH{!NI}u^77Hq(f-|35P;#RDBwo5YPW<%0~m>*6_Le#sqgVxu& zD2ku0ysZ*K>6}iy#KE{Dmn5nCKE*y*xiFu_a6TcPN9f36FogfiABYb} zGra7dU>usJsq5j1gKGoKVV7oIRf9zV)>&3HZJUFYZ4u9!u_?fsXTu0M%pR~)vy9N@ z0pA?PXD!VLZ3_T@qBNs`zrev2#3Q* z9mkmxk#Vi{kcjjF3fMBnJQa~OATJ`zN~z^^I(@%TDAYJ0B9X|;^?H3uL_U&0An>E_ z`xAg1)X*i2h^zyD0VQBdDP?J`lfbYsroYW6fcZD3U@(Z|IR8O=Z>SdmQmG^tFI;%O zQ7MAK;QtpW6xKO+{ygbul&?ld`8bmSU}k0p+qPdQU;*TEIi{zl85ny0-n~n;TBTO&?(^{QFq4y$oX+KtZ?9f8XS8PX#toj` zy2bYDD%y2{ra4wJNh6!3yu8fl<;xhYS-*3K6IZVARBI~LD#kQzjK|~j+cqknXLWX# zpRZp>uBB4uNTq_^B5XU}SuX&Ofe+7~1rcWF=Xo7SD20_yqhc|%>*7=@c=b94*aq@| z2oDGo2K@I~w$Boty{bB)YrpU{`+BNIM^WemRWQ&k=-k5^b+ zS!8Zuj(fk~L-+tgFT0cCpLD9`Mj$K3hCZfhyo((EJVD z7b6>Jd}B-$!yz;S7>)q4Bh(gchXMQ+u>-gm4D*&~`PFFiH6C+(&!Z-`%MX5z7{UlH w$ARM*hA?C?9L5mE;G-!4c?^rd4=nQMKgw{D2Tku&NdN!<07*qoM6N<$f*7Zd;s5{u literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/kh.png b/webapp/src/assets/flags/kh.png new file mode 100644 index 0000000000000000000000000000000000000000..6827bb1aa6cffd4be1f44c71d533977c326629ea GIT binary patch literal 670 zcmV;P0%84$P);e(02pfmDS!YOX#ypI4I^~~FNy&l zb>jdm0xX{404L-CF#;)?*#I2f03h1{9^3&h0U(V9LaqTMk^?lP123Wh9f02eA_P3E z(f}N&02Xo-Tm(3%&CJXOKBSBfP5~x_0VbBe04V}5kpUcb0VIn77H7M=y8$A71U;F_ zJypDqmbw5Y12~)CDVq6k7r^n8s{-zFTY6)zx(jKsp+6aUO3vEso*1 zzA++!P9Al`qo?WV=?Er}+uPg2!ot@NHPI_bxU;m87hqipH`!cpm=syl!^N?Fh2NQ@ z%mg$IGN98;VZWK4#KgqT5o(J7000woQchC$4pKnDr@5XeXd zlil!-lSMb>haU*66fbw-`FaZ(mmgpMEFUbSQT!cJcMaKPcwe2qjAp|WVBEwq;?a4fE zK96Z3@uDpob-cO1m=B)~me?s z=;-L-;o-){#$8=q@9*!>(9qf0+2!Ts&@(^#{QOv0SWr+veT?Og(zZ$;sB%)`~Y={rC66!ou(K_)AMm^z`(QAP12p2+1A$(m>*H3EQd zw?RAbY{SPG`wGk1=!ymC$z79&P5G8ZJJ(!6#q1hGfV+n_O=K^?J8*($8TgyN-hXR@ zwUoU&MAh}E%0z*CUDwOy*ESf&(%-@A+(#PIdIx)2ffb0{8ag3OZ^PEh<_gjE4){UX z3I#!E{e#GIU_?_vlAiv8=E^2N9?)2lK7b7C6ndz}LFBpf8Fap=jOt(-F5Ut(4Ns;o zAAfcLW|KMH!WJNyM%#^WUC(vss09U9!9>%r!mZauf`E4A2ic;XOr%n&VzOGbMTcNQ vG_|?7MDaYc{hdNKb6VOfo}KTNE*SI+-UvSE3Ffey00000NkvXXu0mjfui=ML literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/kw.png b/webapp/src/assets/flags/kw.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2b1bd73b8465e4c5de5bccd0830a6c2d538234 GIT binary patch literal 726 zcmV;{0xA88P)J8p)CjfwK2=iaTg|T)3O2JY}7hvby@i##;J^<**rAfFx z(;n54U3?0_L!}!4aOaBL`1MD2T1_hT8gFYGl>e@D?{l5Zuw6(A3tU3w}*!>`MaJ$?)DC&WFgUhknNqMvh3Ih(dcJHg&~MCNYJWO zX}o#M&RUrr8Gl%FKVU99z%Th?k}hd8F-v==JzM9iH~?p zcGOu)#Uy?ifKr&{UT={ti)kX_ROO){bPxeis^N6ihYJ!-gh^!j2>2mapsc1mlo7AJ zqbB5q@<;`fCV*i{F2W@+$lwT3f)y=^9f>WBZJEsUB6Bl0_^J>@kK(L7zFl2gKk8-N zr`Xrur!O1k&eomI3Y(TOj^nhUyd*DFHMA`8vbC!xKT&)04^?jgL)r;ai~s-t07*qo IM6N<$g1qTc5&!@I literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ky.png b/webapp/src/assets/flags/ky.png new file mode 100644 index 0000000000000000000000000000000000000000..155596b117391653be98024935b58e3754b8fdc8 GIT binary patch literal 736 zcmV<60w4W}P)XK>!dx0v}llB3A$rKLQ%r zX~%qy03c`p8e0GzVgMXq0VHhz9%KL?W&t8<02x;SCvN~1O#l{B024Yc1_h_Tc_U*UR?oRo+{QD(+=aioQ5XoQq@l9XdibhLSi+M=`I#nHQE zgYB-n+EHw1V~q_~G67pEc4KaNdx(T}hNqBnn?66HM@o%%eXD6NwStC{rId}MjhkE zBXQJEZueS)y#8}(Ca1UUWMRgAMFK-T6m4wLk6-K+zF(Fek=Q+cbW#l1cV#Cxu==il zpGu5=7OXX3?P4l<+fCEI_b)y~yq-4UAvNIoZzbnnAcwc!qeX6LDj>~RNr;WbqZHF^ z#`bp*%dr1ERpEZQjDS2 z&1x}@vwbEwyh3SY*CAgRyLz2d9#EHD3&gKGHo^q06H^j7#*aeXw!crn5bU4 STsXu40000@KF&VkDoH0D+bOm8$@Jk4e0g6Scem zb%_9yssNs|0Ee4r$A~z#oN~r{1EaNq%Xyg5bO3FF4X?W?xUC1Oxf!UaoX%n?rpd^rg zKY1Z`n=1}ML6t`pc*^JWC>1z>%)_SiDgYI?q&cg~&OaOPQ`uR6+MRm0g8s zcHRgB%f1SldOR1Ih$*4aa-5H%B*3%1MMDV~b9Z_cCD9l?A(|2}s7FCb(D-}lp&gd7 zSF#jP5)H(F|ijcqZ1>k6DE}k6}B2Nkq8r)4I`!w9{>OU%MT*~FmM4a zYXKr(0U%q(4Ij@ED8UOF11M+!9aYsFIMWv}03~ArGjgg79J2}<0XB9{9Zbj@HMJNo zybdCs2^M1+LizgpLn~g42^Ih;Uoj$A02V?58cI1DN5>T`z85Z{4Ifu=nr0?cDNKQ% z9Y2W|IXx3N6gzgNw96qsb(W^UKwyw>h^(@{(Dq(!;{X5v4|GyaQvh|!%I3G#xFcYs zpV$k*HbG}C0003BNkl5Z|16UWWj}Tw zsxB3TOlxV(=W}DJSmqjyn3jr+q&xy;nIws(8iuja7^_h7F|wBwMNx9jJ==SS{lrdU zLpXNLS(B=!0+cB|IU7-XY+p@U9EN0Z-`zz98TsjG7*tf8pzKA3zW{iW9|+ZT%eQ zL2w&T-oYP9c-3cdp65f>M;FBG$$NnB`4R{==TQ`K?nCh3fmPZ@U+6O}P1A}ov0-U? p(UEGI)9b8FOA&+BYPL{J^a8HYD;l?i0+0Xz002ovPDHLkV1jo;*n73(G()e5FE}EAjb|E)D|VT4j9B2CHVOG)YR1V_4U{%Ijs>K zs16wP#L4ilwCawPTC{=AAu+rnGQbiYj?1X>`Sl~3R~VO25Rpj6#>Srv6_N=M`TY9o zzrg$R^GB+3E}>$?jgiLV(g27$wHYVD!NKHaaj)CM?x(Emq^Z~K;On2H#~(2E>FU-@ zSL20??3|)?!I$ZWkl92~q0zN)zmw$h>efL^$5>gc!>ra(Sgmt*1k0#d0000LbW%=J z0I$j&zP@PH=6l#xpD(-%pU>#GYCclj_5c6@3`s;mR2Ufr!COz$U;qHn^L;Jtwssfh z1+*mK1s?Vf`2Vjw=#x=mW8E{fQS2uui5do}<{bYR(AOf7u8cp5+hybr{hf*j`0c8aEZL%#qP61MY zP)?F<*)9Q6-aS;moZXG)_v4qh0r)rz*V9i~3(@>4?AyD!GAq80!iRlq5y1cv@pVf7 z!V@!pdk}%t*!Q-x+$=758V-=p!sigvt^?+M-{*BgSEdi$f6p#i6IWMO2NRJ96rT(lmI)ODGj#*MJkeLYaAF?awrd_g8>*}Ojk9YsM|WPcVwqY^QGAWk4TaR4lIW^GRZHGzV3 zGa4RYzN$ko6u(3O000|wQchC<=Coa;aK*VCzMsttCvxV;RWHxkt=ZPs$!qgd+q?h( z0PjgeK~xwS1;Np3)Ibyf&~tMqHd%w#78Z*j7W@Li-}f_oD+Q$%EJCr#m~P3$bHZ@( zxW8>ni2{ZKNoK(pQudwA%J4i;!MLeSF{ENCF0Cn+pw5TGYf*2hkR06h{4Z49ul z^hk_~b3ayFj{B!kldyI1J+6NDD=RBoh{j|ImwnL$CN6*ffc%!Bgl{)#*-o0tvS`E zGS#Lt!-p8TeG`EZF_IWSdJZd%6*&N9VT&j&;G`ncqAUKmB=@l+h#xP95;d7WE69x- z(wrgyUs?cJRLXx9_@x_u94F3`9?ys`kRdzYnI6M+9qoAl>~#S7tRdK}IK+nCy|Z~(l08y9F+$#@OzeE|NpA{lUA!D2h^g8-{nCc9iWu3#hMt1{J& zA=jue$Z-trfB+X=OnMV4!Eq+Pf)&MYJCZdh&X^$CnJX7!QGg;U*|0m!g&mYIE9|Q& z?X)bIC_lYrGRJKTxLqX6d@+nv9j$U9g%~bykW%TcB}8mNv1uo7e>$seB$?ti2!Aks3Sb~XhbD7zdF43FaE!kJyC-BY-qb4G8*)Ri z02Z1By$1b>xCj71p!MJ8ypgwt{qhy83ll2f0z6+#hoyEHtQ`X4P@(3++Xu6tFpc@+ zJhBV|0H8F6qb!pxYvt;2D+q-fO75{JDr0#dYt?RQk#AIL}2s@00000NkvXXu0mjfyd+7m literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/lt.png b/webapp/src/assets/flags/lt.png new file mode 100644 index 0000000000000000000000000000000000000000..ba5a4eb6f08db4761c0ae561fff07a40e039fb41 GIT binary patch literal 592 zcmV-W0T&mLAQCe-77}YyGZf+0cIq; z*=O4Fr9DQ%4B(rUHn*dl*$gScx+1rTojEli16mMH?*Hy=rW5{wVeycW-I>p7s0E^QAYN7yTobW|cF|*{2)$sSA zr((tH@$vQ6F+lg(+tt<8!otGWIZ4AIF8R25}J_V%(;Vbh(Zs5?xVKu*)s)0rqbgdZ@( z!^868o8GHHrCLR_bJok;nqA)iz4i&(EX%UIM%-lYKNCxEeGcp3=^1f?g>SQJIa z<5>ls{?gzBDj_KULxU#}t|y*0LNpyyT6+?HqP~4UXzq1eiUQ{AL|MO~m)qeFZvTZE z&U4-sqHOE?KfT1slKt z787eKya5rp7cLfUD;IJw7hNtFb1@rwHX3_24__h>WF!Cp0O$Y{>i{0=02vvFH5!gM z8jCj~okby!>ra&mGI5D>ip5V!yh9h5y3dM*$E0AT&dsLcm^PG(G?Sx8xjRa+%?M+Ieqh_QL{{OI1M>MN~aFs-Htn zxg-E`vEHHR7`w~miGUCi&XVK-+{b24i{flM#=7JDg;q`@XB+enzh~Q{p+8MJjh&y` zFp?Msc5mp@y@4bFqr2=56UJ|^L1E|z((|}5#NGFoq7e5dlej4SzfMCy=3)3bBdiVx zEWX+lO*2hXjl6KaAY>ML)&`!moln!$Fcd{mRav$?8Z?7WvaBk`ua`3q*6Z7w8qf3B zm#bA2WfmoiHr^f7_V$0ib9Hficv|5&u5wS^H%r_*(chIS761SM07*qoM6N<$f^oIk AJ^%m! literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ma.png b/webapp/src/assets/flags/ma.png new file mode 100644 index 0000000000000000000000000000000000000000..b3010e2f327585099eafec372ec8496837fec751 GIT binary patch literal 469 zcmV;`0V@89P)B7+kalO`yI6d0Z&Cz&HCr!6y#7#yoIIjl1|rz|p=Bqy6BDVHN7j~O1qC@#Gt zDY+pgwjLwKGC05`E5t7~#4Iwg93aUxJdzk4$~r-&EHI)cDx4l8svID#H9DXe9I+%R zkv1=h6&SfFE^R?DzBWC2JTE{@FJ473wl6h-5f#KYK2t<3ZRMW00000HbW%=J0Ls3; z9o6P;xTl}k%~dZ8=-FX+TL1t6<4Ht8R2Ufr!Aox2Fcbj5dHhI5Y}G)~EV9h~@6!u( z(E|NB2#kOtMQ0|IoM#pz9ZSR;Q1V_*FIFhL@v#i{@cOCUhe)&JT z%*@Qr&d$5LyR?Oh{~b8g79;=v|I-*U(iSeu48cDI+1c6PYk1WjIlLP*-QC^npsC3kGv(#wr3)J9ik8M4Hspnm#1<|3>FdnQ z%>4ZP?(XjJyTRH_UCR|M)-6TSqp9q!w8zKC^z`(xNK=VMt+oIF04Q`)PE!Ea#aCwj zW@ct)2cEFNkJm56{RcO|uv?jg>OR|5>guUxUEAuBEoK$gXN>>=0NF`IK~xyiozYnr zfj}IG;St#*`@V1IhzO-pO+vEs{lAPcBb?*HIer&&@jm~%0RV)(dCKFVA_XrS26$|w ziQZCiVOfS@tVNop%S<%N)aGUYfp4Lzsvrm)#|;ka*<-`G3%C!^c%C1RMlT@exQ`$G z3F7BD?gacI={S=~q=!UiSD2P?$}D82lIVPqP3=uK(Bo|J48hQ?&p?tInkd{>=dS#sL1%0F(q9092!%1t1k&rV&q_?!y8F zLXrHz0k#An06LAW1RcEyE{p>g*0cq}ng#$$nV5Ma=e!4sM>PODn*dL+r3NM2tp#>x zDiv0ulWio|dVbM8krItOuq@(n6C0-! z3C%(FVOt1O;XVy^YLLkue{h*>i6a*UM2nq<{z{|2fz79}87B1cQ yMV*h|uAbZ~7+bze?}p70c)zwjWK#F|;rD<0!(b?TP#X;Z0000yn_J4iU7uu0LhL3$8Z3*cmTM30N(%r;Q#>Q008Fz0OSAw z+5iCM0083v0O$Y!-2ed9007Hf0J&EH&0+xOD*?w@0MP*ev{C@RTL8%b0OSY&>SYGy zHw5&p2H!>n(-Q#eeF*WT2kwUkYldy=UkB@R2+R@y!$bhz3jyB;0N4ot<`4nK3jol4 z5Y{6A=2!;c5dr6s3FICE>w*Zv8vw~_0OvFV!d(E@69B_Y9@07h&s6}yE&$UE0Na@k zAJUh!NcY0019!QchCEn`>{sIh8T3Q@@kDD7_UXI`F9&R>&*&pwJxlViQ)__^;w7#r!+k3b^ a`uQJ5)`<)cC0BU>0000GukC9&>0=m8z9*uC(aie z+%z}%=_}}vE%)Uo+bJ*IK`GcFCD}72tQHx+uN%`PD(Q(X*D502M<(NBEBfp^_0&4e z8z9&^A?v*_^Uo&478&xzDdKD@YFF!g(JQ$Gs-M1^vpN+;WO~KGr$=h@3Al4SRnD`IlC$^ zqZk+IdOX+7IO&8k-%lvRg(BIoAokoX(or3!DjA#+6vQ|#Xr0683%i}W$>{zI{>hL(T224}0dGk}K~xyiEs^I|!axv4 zS4AoI-p(3!$)*A+LK2b~Ab@n~7ErOi|0_5-`2DG^Q~5EVAp|fwA8}o%8`zlXfk8+*oz9fL&|RLR%B$n57<)6K zx?aRHrF#%m@kLvQV+5ZN)#>P&mB6&e0^o!MJ*ctjPWykOQ+sbsCX-UJ?nW(^BL=eu zh_~i0Jav5g{34gjW*d!0_}Hq)$ literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/mm.png b/webapp/src/assets/flags/mm.png new file mode 100644 index 0000000000000000000000000000000000000000..7e53f51f4b4d5c2de82c7a77ffafd8ec673fac32 GIT binary patch literal 709 zcmV;$0y_PPP)_vI4XnDbT0^x?>l%94WRODXJA8sTClo6(g=2 zCep40wHzq_(E|Vf|2nlf|IPqExIX^M0O=(*_es{my`*AwHs+_B_V+zB7-3m%oBzH000$qQchC< zBgej^Z`H%R)m`Qb=C5*}9WPb4l7rAz0003nNklhf%U}_E83oZ=;q>Bu>DWEjzAp%A6ALy~*jaS%fRJKnP4dA5 zIy15Fv-x_~V9MyvgwqvjRk z-B07^X^o86o72whXzyd7%!q?08J6<~f2y5ZGQFH+BMYj1*IXilaKz>Pw&tRHNZW6C rvFd&f#a62XwwvolxQ*oArYQddm=1RTuK+^_2y%Qe3Ej|Kbp4%ic=wT?^E;!*dMDdUq_pJ=V8YtgF zGwEq3@|qFufhO*Q9>CN10ssI28gx=lQvqtpztya)vK`fCEUWN7unBpM~UtSihZt^9*rNUYNB!sW~UD^FMJVAGPfZJ(Icw2zT^7Fkb z0*Qxrd2b+EXjjmQ*eAqjA!q1FC%e#DJuhUfqeFClH!g23EOKrw(>pejeR|IIG|EjT zg2|9Dq^jkXsbXhx+(-xkEvw9bnU0WfZ$(zkN;Dk_q_Vcwsb+<-9tnup+F&k4j1VB& zt?Sw2FLMzigaDH7KD^HJO`wpy literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/mo.png b/webapp/src/assets/flags/mo.png new file mode 100644 index 0000000000000000000000000000000000000000..e49a2bd8a6667493fecb945e4ce5182425be5671 GIT binary patch literal 556 zcmV+{0@MA8P)*G9I1FA+4qi_XUQrNVPX(W>yDDIt^Y@6>wn(N<0~O zY8!lQ2~tE0TT2UNQxke<6n<+EcV!Awj0MDX_Y3R_BnwX15Un=FQ5lfbvJ(#!t-{zHy`PLx+} zp-m!wWpJ#a%i!EYm4)H$>-gCfkpKVy7IachQvkBRbJeoG$6XySa@gjd3!TrQyXfSf zWtVpV009(9L_t&-83n=7ZrVT)hS6{T8Ltf{B0!}mQuF?A)tmOBy(m!=#bCy}({qen zo>r4M##r|&0PNh##VG?s$}D+*bWnZb?0x_$D?yJum?8RBae&H75J2xC4BB0))OC&E z%-eU6uuKPgW?TuPpPnE6aQZYQt}=;O34*tuR;H_r#MKt;l)Xn!ZaHkbxW0b6)YwKR zh9KcmHvaMQtBQski6B9x^pW%MO*FKCw=9WZx>Kh+)({Ibjyl5ld#YFFG{k~aTp%p@ zVjRoYWMaV?H>(3!;>u&o_OUL3GjDRh5*O_^!WSH7z&#}`2^)JBaF`i;ck=w_;{o;Q ug6$swL)-XK#!_woaExwtpM%HL`t3ghl!8B~f6;UR0000(?(9oe1 zCaMx9*4Eb5($dw{)zj0{lnWQe#>UFZ%D%q7+1c666D0rt|IHF8&l4)i5G2bHCdLjS z#0?gveD#Lmvnst+Qu7cK7N<^KBn-^|bD($(tR z-tF1k`RVJ(7%=qo^v{)S0{{R39duGoQvfFm*vjTz)v~g?a=)L&#%LWI!OvBld%Ec4 zFQLaxV3q&?0O3hQK~xwS1;JZtQ&AKE&~+Z^Npd9*^{0YLN8quzS{*ZnHBzesd~c_mP+TGio}UWbS|GPxU+CC2T(WyYEhdD83klZlnNWo&CLK&H_gq>ySuxo5Fi0(`)YQ<*%K%R|jR+CJ!NJDH#=gG3|NsBO3me4^9?K6S z$PFL>Z%_boQUG;T0C-pcX-UoyCIDqd0BlVB^!2?88UTA-`}g+l>*$yX6`>0m09ihd z2ND2aM86Ovvk4d8+u9X?Via^%6nb9${QCe@ImX7qstg;ihg#d$!PL~$iLIp0&9tkq zo#Wxl!p_JPZ&B66r|ad@yStlnnu(KlVuEK$iCQ&#P%~>nCZLdme_llrVo31<3q}9{ z03dWyPE!EF)v~hKXdNBs#Z?RB!_T^FyP>+Boo0J4%H*MY$}c^GK_UPE0QX5mK~xwS z1;Mdy6G0RJz}fXWXD)Xpb|eZAB^70)lzcuFKahd~A&>wowsc8g21(t+|6Y=&7FWkliB`YafHCwvMd8Y1ZE2PvNP?({2eeO zed6o!nYVv#y*fV$HcPC&q;Wn~V7fl+YriMn{m%dX1I{;YoP9C^`Tzg`07*qoM6N<$ Ef{v>bXaE2J literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/my.png b/webapp/src/assets/flags/my.png new file mode 100644 index 0000000000000000000000000000000000000000..66b81e96f97722ba43cd589873a043c03a2a10e8 GIT binary patch literal 845 zcmV-T1G4;yP)&dd0s?0P18oQh zZ~_8lzrVi*17oWJ0j>oF%F4>80Rf>238er40RV0Q0BXzv0ssJW|Ns910A$lHF2n)? z3<_xg0C?C?Q2zY<-DqgqUS8|7vl0($*F8PsjEu)0ANc3z{rLFeeSMyTE%L?1_SV+Y zCMHxyPS-_6_0G=83=HFSb=g^2O8`XcqN4G>zS15Z>YSX{d3nnf72tz|@3^?90s_lr zWq)ZpMLAS%TS55T+`&mn_~Ya3t*zyimB0-Ru?h|M@9)32x5_;|x>r}nq@@4Q0I-oH zx|pX@t~v2O)$RrrCU#%w#gjkIkn? zKs+Av;L{7RLyuvV4$ojG@3oK{+1O}nwFDs|V3}ZUQdL&DB#hw0w(@T!> zG>t7&)I^i?V#z5Dbzyu3SM@-oElZ@uo}x>>R|`YWw|~#z;Y{|Zl^QEV6=xvQE9OwX zgb5p62fdzKD%vp{a$LBGRH&7jd}IHssvz#^>8<5sJ6J2yDa z&}7N3O7rpL4=k1sJ0B8>#NN)`-SYls(eL+fZ|-MV*xOCPv(41@?pF5A#zuB)I|cs$ X3e-{u1~-IK00000NkvXXu0mjf?t_pT literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ng.png b/webapp/src/assets/flags/ng.png new file mode 100644 index 0000000000000000000000000000000000000000..d11b535ae9bbacafddcda59a2bb69a77c2205f48 GIT binary patch literal 601 zcmV-f0;c_mP)%Yq2yjROf>;G?LI6-J%*@Ql%F4;f$-26_0cAWDh+);$ z)dPiG0)<@xa774*VA9gk09G)yy14*REC5g_0BAqP#>N0>J^%mz^!fJyflL5?N&tgS z0FPY&cS8V(Qvi)v0CGP7dPM+=RRDBC0EbZkl3oCmVE~q70B=A5g--x!I{)_zv*WKD$ronPM~t)2l#*S z18ip`Kp;(8t&%X+iFq%0*!npe!%Nu5a0U)*ABK;9O+4KFebZh&&X=9n`*&jaQnRZ* z{>JI)G=C5DxJ^UnssoR{g@aad4Qb%2>j0XPt4Y*#eF0F8xo+ERXkE7$0J6FXZHks| zNeMu$n=mn8NgMz+!ZerjAT3D(Krqc#LrNqq0EEe`B?D4Z08F!{Wv?MViM~t2xDit$w zp(i!@`1q|UH^Rin8$5ImB~{ICclPx3VtJ_5)zyX*9JoqUxjRNpXq`x7mhIQrQE#Q# z-syLaxHM6W!BSk^rK^q=Amp>R!&qUKr^eWln8;mcp|Z|4NN?uq@zc}Oo~pu?MN64D zwi^Hd02OpnPE!CQBg)m)Z^Ejy!d-Ia=ABi$3!w`=Sw&d@007!aL_t&-)2-3jc7iYz zh2g`P;Ml4}P^l;=5KzEbJJ>qB|0|gQExJ}O{qFMba~A+XZC7Mj)|%>z(1M(xO7QBs z(~LW2>XN2umHOc@2ltJ$7~8f@ldnUj7>dpp)9f+DaG&$e2g{0&l+s=DmsuZPA>N02 zZnAv;q91XR?C5^!!EB=cBlO_>#9{rtS*43rwE6VbzelIb4=hI9fax;ZG>W2hOPm1w zyWBN6LWtAPhKEjg&juC^pFfSVXM^uF5XKla`lTR1fU08gI~}oF!$?sSgli()0Dyxo UNjks-=>Px#07*qoM6N<$g0h9{ng9R* literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/no.png b/webapp/src/assets/flags/no.png new file mode 100644 index 0000000000000000000000000000000000000000..fd96041fc8758e4948dfcd22e149b8cf24215b31 GIT binary patch literal 670 zcmV;P0%84$P)H8KOy3lMX493@U&I8hiy9sTMypLt>^B zJk7nqe2bw0DP6vLjWSSoy{)iqb&3ELK&)+fun8x*3og41GOGt8wF)Z$A6Wq?WWEnK z#1uoN2O+@|KbZQY|jRzl)pt{_Oo2e5$lL#TA z4>Nm^?AhJusJEkNa=OCN z^XBSPUUqeU*L%;p*`5Bk%B@-H+r9t* z0V7F7K~xwS1;NWs6Hx#F&~xvdmgy_N&|0KMLR_Gcg^9o5fBOdpcCJxV#SkoQX)`mO z>p4N$j@QRU7h+rIyamcO73rgKXWMKC@9(*dk*>Qm37>~cjN)pxYIx%jHl~FoBEU}# zncPN^2peb`GKm5apg|@<5$EHyo!0k!$lu5$+`JTni>G3n)J{TuJ=`^e*!`N1!fE)j zTY`*5o9BNw<3s2@3RmIvx=gx-OI(q=bGS z;o9Ub#;OauqjQB9=nu=U;pkB8fl&yDeV*(M)@8T<4;<@nC>XsNB>(^b07*qoM6N<$ Ef~RdTrvLx| literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/np.png b/webapp/src/assets/flags/np.png new file mode 100644 index 0000000000000000000000000000000000000000..11e35e7d3772825b5ab36aaf95d779c16fc88f60 GIT binary patch literal 612 zcmV-q0-ODbP)iZ&epe-E04|FUF@^?Dq6J5s1U;1pRi-~Eat$zs1XHM099a%9h7crm3NeT&GJ^y! zgK8{qa3Nk%C2Bb-a{wfHHYs!xD}Mr1r~*@{03mMx0Du)KdO;jsDI{%H8(0Y*007t!G~ysm|NsBq7eUq!F~lKK*%CP0 z89l)nNZuPq+!H(04K2nhUY#9N-yTS!7e|R5R_Shs`Pkp>f|;xyQuD69%O+ajIa&Pf z@%F*Z=UI5L5-TJJoWJZmA>g_Q|jn$bp!67X+KQ#`k!-MB-H& zQ`NI4_yJIC&tCul0NqJMK~xyiV_?7oc%-Cxuu5=Rx%g;mV3p9<*ALQC$0}iCV=k|tnOVPP%Ege1XkY;JDoq$6x+ zW?^DtC&4Yif{-w@F|-yCV&vvxX6E7(N9ZtgbPuz!=0kTCyRomEfkB|DH7kaMzkz{) zgR6-xnhb}Ty@OkTy{Vp_tt6U+MYyXeP~6DKR!$aG!o)-mByMEssUU7~dI_ah@K*hjkBjQq;Hyz)viARYkL3n91~i3oZC0000QU)Pc114kzE^7c1MF0~-v^-alc8|SP zY#2Rvx@LRFe2xGgWdI;)03BliC2asAYXB2T02^HZ6;J>gTL2hV03Bff9A5w#SpX0| z0V#3K6*vtra|9n=*m8*5VtQLfb^#+{EJ}qNK!CYpfY`;*=Z>EzId*eHYXBQpK~IAc zHFnN&iIADIUuBbTh_ULOtj`%g;d+phQgk~(c_S@k+M=`c)ZF3I-o$*4?XJ7hL0>RC zZl@(Xx2Vmn}(dQ7daXtsfhpp02XvoPE!D`wx4Jn z)x5~&3op%|ReM!?*TdPnQo72w0003nNklzK$)*DnZl*p({X?wJu70lBn&AlTI~ z-qG$?r{?bo1doeQa##$vo0~1uG?WT)*rFdE)T^?7@mzRnH7^GL^nQn~y4TwE-FA9A ziL#qMtJ4OWw$C_n-yIl1a006SjAT@1lLI4&Lx9Fv$clL~Mi86;B_SEbykIkkL!cyt zj2aFoB2EOFl1R>~Uu&m;Ub+*gCXt*`v)7IS7{Ow0@X%So@VnD*C>SkIFNAG8`G($dnyEH(g8AOKGx(b3V+(9p`t%DW#Z(b3V+(9i%!7p4{&x+5&r)zz~W z9MjX&)YQ}fS0unBF9262!7exeStzg*8~^|R-x(s?79ZXhAmJV;+7uk%8ztf)EC6gT z<0UWva4`UNHMSQX1!*k=U?}1?K=$_byC5gqAuHf4Hvmi?rw4!K~xwS1;N{D6F~p~ z(6h6%X*LOVTWz&Q@ks^owg3MOQqVUMtDq7k3EkYLGvhg}p)K)xb_(b7I!M5*?PK@$ znc0`u_rpoYU9<0`>4!Eqw?~83X|jRA`H6{*dF17ytN?3kU=aAe-8}^WAoA*~n-bsk zp8#Old_16#bvKiAmTMK-5`Rbiz95r-NCc|(QlyXxBm$Kd5i&s{pwdD_79;{HS`f*C zw0!>WqJG=Z!tyG`G!YW+jiWxi9}lKK{~jKrhmZ&usrj=)XE%u+tAG)eg3f#=(p>ed zvVcNo&h<=uC`}JLOdF3+1bew8wDOr*4EbP z>FEe6UeFI0s|*rsewxh<6zYhKr3w(;86)+`$36&`|+J9P_V)PO+yoXo=z4#tv&DLfrlYUJ zv~F?HEi$;n((tgd6F6(8w8z*hG1n?E>zbVP&(PK@FgpxxaR2}SA9PYqQvkdZ#nsiX zvUa``RUn__X31vC=;SZT=$)Z2y2HC_hSb8J0003SNklr6^|O#MIcv%tVXua1g@DgZ^ZFI|89RElyvq zjvb!W!{OYgv6|?X?^MOxe$R{T9_uowVUkxxihnK7=1(@)P|?t{5>P)b#}d%c3xNc6 zm#!f<^aNr^@V#09dIY8c#>mTd#vS?6I=ZaK?$ acZdJQSZsBy(VlSt0000w@MDa;BP)fX?t3K+Zz7T_vC*&8&c3KyUV6VwqU#2PX8 z_xIigedn42NBfN)Vmcc-rnBb*xB*%@s|b<#>U3eDnQK4 z%>MrVECb2F0000SbW%=J0BCz^)yde!9j{fl$}bC{y1V4)ox{4FeuwcT?6TZ9`_upyBEA=~3|`d%34<9E0n z@?{+20~qIBoSv9?_%ud$o^PA&@QZj}C(Orp{Js464)_;$dHli*oV^}4O_#?EGXNT0 z-6YHu0CW{;FjD~NUA63Gt3s{-l?75$$N;cVL`J3oSSXOm8URWwR!NGgX)OR0>o7PZ znx+F-tpg-99RTY9(nafC1uzWQ+hw-LFVP?GKJU-Ko)EveUXv8ZKhl)q9c_PliMrGY jINtj7^LF91dn?m_yBkw*c>DO(00000NkvXXu0mjf8He)Y literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ph.png b/webapp/src/assets/flags/ph.png new file mode 100644 index 0000000000000000000000000000000000000000..c3cdfc7423503d1efe942e5a747c404aae744918 GIT binary patch literal 671 zcmV;Q0$}}#P)mNY>lt0000LbW%=J0B*A8$JKAQ!POHdpPwh@6IE5a$w;@h-2eaq zB}qg{fd-5J?%XjxVw$@6?NF`xH<^R*t2$EInvTCHYtxL8=0qKHZl ze7bP?PB`+Wsx}&pS`7!sfY4YUSS2iqZ4z7`zL|`vEA4aOc((1@a5-a4oCISNt~>~z zX9vcPgF6btkq<}rk0Rs9Z!SRB-i9k5!pTFqe0_B}7)+;wyIV~MU1Ph6ZoE8|@#^(T zrBba*IC!4z!&k<2%vFg5{Em-5rY_Sk>HlX*#$oRfAV3HO9bFf|X{^M4r<6U8r002ovPDHLk FV1k-gCgT7A literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/pk.png b/webapp/src/assets/flags/pk.png new file mode 100644 index 0000000000000000000000000000000000000000..538d9f9fe42eb2e8e1cbf2bc2c2e7cfcdff96f7a GIT binary patch literal 647 zcmV;20(kw2P)U3V%E|yc9RL6S067-{I~f2$8~`vA z06rQ3N+SV79sny405lc+{rvzp763&b07xMLKN|p0C;&Yf_xJYzG8Fjv`PkUl08J$T zE)vw#)Bq+903r;}&(9K0EC4AF-QwReVn!`oKuK|0@9*#D>F9czh=`_?&DqjWd}AF` zGnTHPV1jP-_VxVy{AG)EgQ1WoV?(XNwirh%!_UaBNb`yS0016zQchC1)=*pq6JbZ=G`P)(}*Vmp2A*&86*Vot9*4EI_(9zM+qzfj=$;q4z zBh%B5i|e)|JxEe*%3C^5HsBrKGhE} z)DAAu3@hFkLizdm*x1<03@Y~a_VDoV*B3yf3M9l2Fu@Ba)YR0o4>0WP?C9v|%gf8` zgqO7wHoFTb#>U2;2_UcwCYcBw?vkbJa*XrP*5+q^+%r|p4>I83;Qa6M^RB$!B}w8& zWbmS}?u(%BpRMw#x%t}RxD`6bc#63eJhESHF-bfv0000TbW%=J0J+8H&DhDh#n_*( zpC_;8XcHZ1dsQ!cRSUtx{xYbyumAu6+et)0R2Ufr!MRQXQ4|2sb01H(88k#w9Ui2+uyEX5Eoz9PE9jWZ>w33 zr?dfJlm~%*+Z$&E0Dsm%Edc-kq#*zRkP`p^Kmq^&$a*i`X=^_i9?s?fX48DXo})$a z7|m`_rNubnBgXF0bxOF=-8f;xX|DG&&f5M(F_1dsB@$dte zJ`n`^vZ>c@q%Ga^c%s-d?5*p!Natw@+?1}{l@hL2{j~fCRD?x+VQov|00000NkvXX Hu0mjff=dR` literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/pt.png b/webapp/src/assets/flags/pt.png new file mode 100644 index 0000000000000000000000000000000000000000..fa26bd2e38420e5df442d02272be41cb52ed6ba9 GIT binary patch literal 693 zcmWMido0ue9RK`&=a%zm9*s-8EX%db5P7sAuETlVa15)2o!4FG)Oke{Dq~ABYEy1B zMc&gEdF00BRMNS5S5xweA|@+rUB5rRpYLa%efIf&_bCbC`x)w4=m9YF=W)ZR&7{Ue zmquZx<3mOgg)Rr8{L2w*a66FKnw^08HlSu252c*rOc@qp)d|e zf{G$(Km-OT5)H&)0-$b%lmof=Mu`G^1U7+vI;4*HdI72gi>BVsyTCtS^%Az(a4p6f zhc+w1l+2*2Ot(dW+5-JGHY?d3>0X$w$FWSv&O&{QvDAedby#*WohYza5Tmw=sh-3@ z&dJa#2ZNH$obs@3d!n0Xr1UUb^fjLonC}=H%kyeh-fFfwo^{p_{t!#Qh+TJID@&TO zJ2UN+nN&r6$Vw063oto6{9Mt#%*vgKu-ETp)u`n?UE;@yiHpkbB1zNV9;$MNKi4~) zc{pJ}97uXI^EE4SxrQak(mXM+>hs#sYW=x29CAJI$hFZ7sZ+r`JN>RvrZAeLZU_*L zgdSqYt16Fn3>^$6EuqzIfN22 zbPXwu7d-g*_>dYufe|ry4lCE!*KQ3bTMHm{6ESKIC{zg?(9qD9Dom0bLu3pj)Tp+7 z7dWt9c=Ye_)YR068a}2=XWO;G%*@QtqOi)s#QgmH3s0V z6wsa{eLVi+RdQ!kdxZP^RY%_+Qq{5gtj-&?UE9MWyN94mgNF+Mr&?{j1$cHI4D=B2 zl4r@-^z}OvMzGZ+vi*z^O+?p&=!?E51n&{iX$o^H2Jj$gQf%2xB7`4UDJRKgG{s&R zFp9#>sT(5;hJ5*OIt7b@0$eVWu#H_X2nkQwM|O>l8A1m*a;`c_c!_J{F`29EDxNlo n@b;w=njzJW$+{}bTmIWOCU{!o(Z;1|00000NkvXXu0mjf0R8Qd literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ro.png b/webapp/src/assets/flags/ro.png new file mode 100644 index 0000000000000000000000000000000000000000..2ffa695e99e365f312c85711e5dbb3d40e28e09b GIT binary patch literal 515 zcmV+e0{s1nP)*WB?#y0483C01&>I5XcQ6{nQ%E4 z{LT{qDSQAtjQ}lx{nj1M5GDXNhyXBy03&Pr$r6zW5&$!X3pIAU4j{1!7X&SE06>uV z!4ahl8x=iz6-9xZ3K-U`68Fay>A@M_yBEfu4J^Zz;s5{u8+1}mQvlfLti`g)*}|@S z#b{LvAiJS!of|J^}sgf$mVL_PxpNd>$ii;y%u|xdRWS#NCDS<|&1D-%I5-bNhHCCKNZWZ^)T5z7!N0O{mUd*c}2KImMLTmKRBTz&(W<=^`*iI_hA`VY6|PU*6tI;#Kx002ovPDHLk FV1hiS(V+kU literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/rs.png b/webapp/src/assets/flags/rs.png new file mode 100644 index 0000000000000000000000000000000000000000..5673b0e45324a699e936e2ce0a2eb0845144d702 GIT binary patch literal 786 zcmV+t1MU2YP)L#=gG3$jHdEM@*td zMy^0bsysl$!^5JYqPe-b|NsBRG&;yRKM6K!3p{bjK12vJWeYxZ_xJY-I&Tk3ehD~i zvP4S0F*gxSf#l@msWv~)OHs~9PRm3}&d$#3>gnj{=ke|B+@+?gyUofN`LH_*wb&ssaUQ)k=K+K74>f77JgM_@i zybwii2P#tF=IkI!c}_ZBFz=f=k$G+T00Bx#L_t&-83n=JN)%xL0MPS&Gow4Q?5yOt ziBc4iWh-Uo6BzZ(K^KAdqWDu9Bo-^Qz7-fjk+d>AW{* z%(?0wQjHrzTz3{;bUthdFDJ^ApRPUAJ>i-nWkYD)jKkpdcaGFFv_%AWMfe-aYwDT? z9Vy}dQ{0<3^IMGu+TJFw`svlL?w`4xB3Ws@e<-?5Yi@es`Dyrk#OO)d+g+UcaRzlJ zdRU4^Mw~a{Y7^=@6IF(BXrx>X$-76W>nsziDj_PBPG`f`MWfC#F;)_!%6!m%Jih(7 zz0#f8npBbK&nIdBOGRAW-dUL5+qFC#(r$0=uZ8}wLN6@W<8J^KkJnP*TyX$^gVrFW zRWx^8_6AQ602ET?zvJ5nvZ-|!`NIBW<9;dLd^OQY0JBNiDPopovFIiK1K7%Tqc{;E QP5=M^07*qoM6N<$f}$~gX8-^I literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ru.png b/webapp/src/assets/flags/ru.png new file mode 100644 index 0000000000000000000000000000000000000000..ec551ba6170c883869f0ed2761bd2cd0c5e2fbde GIT binary patch literal 560 zcmV-00?+-4P) z84=Rb(wG_%of#3H8WN%$6x7tzn;8+))6><})zj0{*4Ea^$;q4>64lk!ix(HmHyi)| z|H~j105_!4C>sDntkf(X05qY{B^dxZrvN~z05F^YJ*faPp3Wi{04FW9U`N$m; zw;B@g@bJDL71-F=ge*r0My#(R8<;0NtsNAk8WF@K7}Ci01|@3xql+H_WjhqF3H zNGUmdR;Flc0O+`@JvzP@uxI>OahttYyOxhJn;hKHq?fY?6bD-|`*l`7WkAM15penH zV9S@kK2$fHJ+Od+!HL_-Q`lG*p4)b{t~rAUA0Q}QW3iz2nh_-qZ?a}yGbN5d6g2a1 zMt~T#=7F%;#(v7D)P5R+oi71!KzmK*MDjE{JW|#$h$(D<9{hDE8#mz$*#Xs4sF@x( yqMzL;lDl6(>g)j89lNHqy!pIqMO8{wVL4xav{TUXY4qd(0000tR839!`06QN5KqLZMI|N2705KT>PA~#TDF87V0!u9bLnjSJBLGAu04x>(J|Y5H zIs#if0$Mu*R5k!M9s^=S0Z%XhIv@Z+BmhJt07fPNR51WiE&xm_07@wUU_Ag*F91(0 z0AfD?S~mb#H2_vK08T3aJs<#NKmc4h0A4!)Nhkn19sofjPl|Csdtd-ZCje$b0BJ=4 zGa3LuB4U?;R*iOQn}z^49RMvC9cxQ3cUnJxV<>7(3|l!FUOzT`UtE%WNrr4Ta8^fv zW*lNe6lX?tq>+fOo}0a|y5Xqh0000RbW%=J0CRH3yUL#()x0mCuVxG6zpu~N*;Q4$ z=;;1^C}gq#00BBlL_t&-83n=3ZqzUofYI+xeCga7mvbt@&2w(+({{UXTmc|7CU*^$C-3blNlSk66}#^lT{8j(9~9TD^$-Q!Xo73O zNaURs^VyC<)WYJ-Amw{BcMlFhB3d*xE*s>138zCGLc59ZSX3K1NG9*?C^y|kUbgr3 zT~w1&O~v-sIK;!kxlc`Pxk|EJ4zb-^%Q|S8K3@NH`MFO?dc&!;mSuj5C~xKNIAo+( zknh1~5h7Yplq8&)NAFa!2=NEM6(u3P&PK56q)6O!*zfnC`mMj|Y)r{JiA_|>qmmFl z`7ghWDP-^ZYt*8u38B}w*K@Bjb+07*qoM6N<$f(sJ#y8r+H literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/se.png b/webapp/src/assets/flags/se.png new file mode 100644 index 0000000000000000000000000000000000000000..a76d07726a86c399ad7ecf088900db0f4ea5e52c GIT binary patch literal 657 zcmV;C0&e|@P)=)2{&mMtTBY zj{#1B05oF*RD^JYKyHOb0$GUyT8jWIS^z+DW^FZJW;Fm&lK;&B092F!OpO1~0RF}R z09Tg&$p8RFg8*Wm06~EOM~47rp#VO708fwp!vFv}bpTqK07{De%K!jhoP3i`05@y^ zM0o&Rj{rV#gPBYKP=`Twak{4^(X0pnELwq$NAS1+GGlHOU5F!Da06nP07-o5!36*? zV10%~cYQx5Y=aV3gpig$*t-`)X=EW^bpTk2Y=ut4mH-?>R(^>`oS{97l|#U=BEOLV zmv|Ad0000UbW%=J03F4$VXoN8$GdV1FWIl1y5wu1OBw(G0UAj} zK~xwS1;N2i)Ib0L(AViuWLH|YA}SCQ6Z`-#p8Wrl;i!qx8v>#UZ0N8obY^_7(X8&< zRju2)TQ@ZUO5;nCsG>FD@w^_>NVz314SbE)LX+jEybl>Pf~@$dE|hB?j( zFxXzX6oOV{R`iN#uq6w$HbGJJ={2@nISaIi>t1|#I!vb;nLtb1iT1+9)ywOC?cfbh r&>1&hn-FpZmd#_gNTq6C-EHcB5MP5?M>%oJ00000NkvXXu0mjfi02NT literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/sg.png b/webapp/src/assets/flags/sg.png new file mode 100644 index 0000000000000000000000000000000000000000..71ffebc316da8ed04af294fb0e4d1a757a551a96 GIT binary patch literal 649 zcmV;40(Sk0P)=C? zM#(Tg$1*<1GC#q=!NbGDzP`SqqN2ve#>zB9%sENzC_VrG|Li3-?I<|zEIsZjJMJz& z?=V5@BQpH>`0q1A?>0x{A~3icC)+7F@>gZS!NKY2>G4Wf_4W1S)R zbAIqVOz1a9(9qEHc!K6IKlYTJ*fK%MGC}?7>-ep-&CSi}Jx<5R$Gp6}_^-9~kC(

    +}FXZS8 zRePO#RWA$5&!HT)6W9O%0RBltK~xyimC;vof<>dM_gw#oZ)iksgEqLhP}p zxBve$0vKX0GS1|?e4lf70LW%e$wJ|T+}qvR*anMbGYlXV<^5wkF&)S0w%c0_P1DS# zs;WR~imqskW(Xk&8;#3ky)+ow jIy~4fQUpOz#i)G&i?~5*z2juT00000NkvXXu0mjfXzfgj literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/si.png b/webapp/src/assets/flags/si.png new file mode 100644 index 0000000000000000000000000000000000000000..4f3b13b01a3bb709c1bcd12ee85e23e996cd638c GIT binary patch literal 720 zcmV;>0x$iEP)Es`9dBIye- zZ>^nm9A{e6bdn^+fq{XLO&A*+JLvoVMPtlmGc`5!Skf8a_e~H4c%FyjI2dEF)>5z6 ziQ||kim26UL{S7FNejT7q$QwYt#yGWpd09LUDwoVHC#z&v)L?}OlIA|>MB?ZO-&o! zNg|3O6Gc&b9LMeTdL3&m01XWdc%DZP1SCm9KA(p|p`dEDsvq3F73#qLWwo`g>i%F^ ze@dlNQ7)I0^Q*)8l8EabOUnV}*`>|Kyd@;zN?I36B`7b(+`QSur;qUYRm9V$aQHBE zbZjqql8Dh!iy0iu>&cUDhK76=7T{dx4A&p^kR)*BbdhH(y#NFO^!Gz=ZyH@)kj=v0 zz0lkYVF)WLFf#+=<1jp2VQ%gPKweN4NFePo-*oTVC*8hUQb4$_Z$Co8s0_SRdRdL@ z?HzDF6WazBY59u{RDelb(1xdvw*4;>|BPkB75zH|DD9w|*IZzPV@%S|G`lFXgP1bA zS*Dq{bTUgfQ}j`!hiUqV@i$GqpZA>M0jIcuxy^B1V&bO_Y>l^MdB-6ZXdz|?0Ie)? zls6n*o8S~Xbf|L>L13Cq528>5K&c?OE-da%sG3jW&0;?bkBQD0|s?wbp9-f5vK>87X zVGwp6f}RBWLg*880a-z-AR|ZwOM+Po;}(X_L+Lxst(_Uqk~32P0000(_)6><} z)zZ?^&(F`#&(8@M9smFT2}YXj7$EBv9qAJp3QC>`LY4|mq3#FMe3A1U$h@b>oh2S1R)79Zae8t^AE1~PKz9Vhwu`K$~P@H<4EBtkK3vCGTL_nM!f zx6=(vjTugn&Jh>I4;Hu(7K~zwN`1P|-0Z*5=2J3jCOwAF8Y^%(YjmB;Y>2v+ahKrW z;A~xx?@m|x!o~DvZS{G8_@}G(ijUmo@wXv5u^%~#6(^Z9Sg%)fkDahwio(M&NXTGx zc56=)&uMPM3E2gAJ|4(@=dBP5`gnZYoOz>IE8)4UzAY7^9TBw(0;Gar@>!olx#7<9GbqpAnb zR&c1-6BHGrk_p#!JC1`VPRDIhAQ?%A-$`d8S$~j7#n+a07*qo IM6N<$g6|tY;Q#;t literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/sm.png b/webapp/src/assets/flags/sm.png new file mode 100644 index 0000000000000000000000000000000000000000..9cdbbb660f2172ef54c05d029e1ee6bda2f77568 GIT binary patch literal 907 zcmV;619bd}P)oKTC4ixa6->TMCZHZwExt_QHX zx_U=MZu`EUN~hBVL4e~ps4CW4;y5M@!}c{XF=3aNm-9egRV%7$jWJmfnFcN!V~nc) z0N??L$n5a&aB5^^>2*$8qp|pG+o0xm+fn&(j6~IF56! zd2wqU-w$|pbb@p`eWq4bJAmVtvufqf`xNsz`koo(mD#t*^k>gBKlVI-*{!PVtPwu> z7A^n8!grsXs~3?DKtxVWv9q_sFH1jC-!D=q)L44(2bW*G(Ka|Qfp@B^CH&&c&lvD3 zcu9l3sdwNY&(Gg{#KNPSyz$x`V`G<2?QH{&iN#vN+J3}tBVj)jG^(>*JHYYEctfuu z?l{i)G)a7c&3}I9>(U+{ymuR8Opp0SCF0&{r2|+h)_Js9;=qd$1yv9fQ79j}}XJ(eCow|SgA zwFfHHO551&Eme^X62 zR@Dbx0Q0Zd_?9d3)xBG9Zk_aJ&VBf4P#e8)HLZUZ h;^^+fkKca+@E__^n&$n3`NseN002ovPDHLkV1kj6v|IoH literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/sn.png b/webapp/src/assets/flags/sn.png new file mode 100644 index 0000000000000000000000000000000000000000..38f9d68b99365ad78bbffc6bd5f90058bbcabdde GIT binary patch literal 572 zcmV-C0>k}@P)Jys0L-86LA7 zCJ2X60)S<5W}`D0B0`%XfKxt2;LPO;TIp<5*XteBL4750Ea>S?mGSNJphkP z0E|iil288eP5$sh0F_e!ibVi~KGYHz`{OtJ=RyN^IvAHps}B?afjpxO5EPO|@90Id z6Bq!5Owt-6$PX3P$2I_PHT~>20DMK{;zJaRJ*CP&yAv1yYca;TE9%-h!__@+vOVA3 zM%&RwKcGR3z(XAHAi@9u02_2tPE!EM*n6^_uFto{XvI}83+TG!8z5_Dq3GlrATiFj zqyPW_@<~KNR2Ufr!OL>fFaQ9+l`6-voF=?_2}}=gWQHUE{}t^Al)`XfN)pg0#uB?X z-p>D845kp~aa(~mA*RhIzw~g(Q*iQd?U=s!6TY5c%?0)j7kKW+1XiG)YW)pym8>^b z4V4OLq>Yt^3R}=bNsF40D);3K8v7;D2m^O^@w{vrbfO@HfvtCsm;3jiBc23)#(g7)q@esy)w1=`qCWF!Tm9R5-tyy)-Z|@a@Wt40#p|^=?z9OffYzm6eq> znV6=ouI?}^$8A|PL$O`xy0)a%?SI~Xn00dVuUBtg^v(Q!=l-RqcP=WJ{CNEI(~*;h z4jp1hF+6(oC_|mklP6Ey-QDf&?R9l^t*oq4Q&TPHDAW{PrD{RuIs#?Uh?bpTZ@V?OH0cy z=P#erusn9`SX1q*Yrr5-C<*clW;nC!-~8Ac#rx*(Ka{bBTlU%do7JKE*UsxdIrT#N za}wB?(hHo%@6og zzVzPTo?mwE=3f2j6G|r^%`!PRTUYIHQ!~%zqzKh(9`hGgblvPYayqH1^5niWbNz2s zftS*g4W~V|n791GrNW&$U7K{=JX>Uoj_^GG{=3FRmZKu{prZVm|4HlT^i8rjes_x2 z)JqpXGOU(~_u4O6|r%TTbFFHcdQ7T!V;Im>!PgV$#C|{Y3Wz%-4iuzW_CCKbt?P* z;mt+LCxRF&fFm%MBt2~m8k8V8cn~0)C_$en zNcQ*o_4fHIC`Th9JtrhXG%-<=5+*JyN--`@I5k!B^Y^MFKAsjW%F58B8#D0m^Mei_ zpC>@U!N;qqv>O>O)z#hZ*?)z4j!^AM@~#9-iyrW5i7}*@xTW9|VAP@AYWiB;5U88a!P; zfGw0(;nTePgEQ&P^7yiN8tdoN^fRmz&O|aD3glIaIE!RDD3D1wi)0-Xkdbmvl<81_ z49WFi*N&auUc&78u|THydWK27S&h^DCa`9H%iW&M+# RSP}pL002ovPDHLkV1jAs|9k)d literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/tn.png b/webapp/src/assets/flags/tn.png new file mode 100644 index 0000000000000000000000000000000000000000..c910bfb3af1a720ef83c628f70d2bfe80a1cf22c GIT binary patch literal 616 zcmV-u0+;=XP)dixd9Qe1Qo*p7{UP;#sVG50w1{o6RrRYt_>Qf013kb z7_I;bp#TS@00-y*59I(5=l~Vx021Q>59$CJ;s6fj01@c`7vKO5>Hr(;03hK24Zr{s z*8mRW03F)^54->l;s6%T01WE@9mW6{umB3t02Aj69NhpLwEzsJ00{Gjj{f-h%K#nf zB{lrz=c@n-*Z>;F01E!~_URBK{qgekm7e#sxcS7${NLj1E&?L$&w&IQN-000(rQchCk&Y0umxf9tPP1+SAY`%eBNl}gK-;dV5ezI2c@Oug`v4FOk8#S2WjprM-G_iB0(x?{(5X~1zrrj5S)uKF=P(`p zXw%;s2^=W&IMQuf^e=*|FG8jtv}UR>wbZp>k1^M?oI|0000Qa1q-(c61E8wqy!741q{X$ zCawk#um=yH0t(^@73B;Z;0P1l2N2{67vu{W-v|@l2NL567Um8g+XWBk5hc?G59bgf zzYH1041q|K^7S9P4=r}{!5F_IfBeDh#ya*Gx2ND1O|EB~Cp#ur+ zWNqdeDg5Z^^N*JFp{Lyp8^Q$*g89kK@{X46b9&4WBS{b~m;e9(7IachQvjcPRUN+7yvVQUFALb@ zW}nY%%C~B0z-&7J009p~0= z2|+>|q@A{-<2gkhKMtL;E9Id40dlr276@gfsuKVinLZ&Wj6{KynxP5WO_Uxfcm0%ZL(DfTLdf@$*qrXOqYFAJ3O@>gFc$e*gdg07*qoM6N<$ Eg5*>U`Tzg` literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/tw.png b/webapp/src/assets/flags/tw.png new file mode 100644 index 0000000000000000000000000000000000000000..11ce8d4d620aba39ad69182241ac35a5cf64744a GIT binary patch literal 547 zcmW-dYeBLz1wF3<|~2HewiJR+vQ- zf^^a@RLH`%Y17DUuCOAuY1Y8T#MVV?Bnq{PEyvb>!8zwS*WZWJr&h`N++r>OzM_uS z5ET&@aF`_OcCC*RiKvy@T6w!N1cWFm08rEo50lAY^ap_{K0nt7Na+KJd2{)768*qD zMa{8TdCcBtzzbmd|A@o6^%TeeSpvr(aQ7?l79g}gKoUrU0`37fe*tR%3)fx(gaF)T zv&|lIn9Vlfwyv((C?LZH9n>wL zrRde`UyqM}OeWKI`v-$zc4c?j3;~yPYE(zybq1zOJK~#{TP%TyCt@)KDjOOym2V_x zd-l3`WG$wk4`@Y2tmkX0FbXl>u^3#aavQq`_L&CbhGeGF)|WR(%Xsc3x$~QQeams* znq#EKu~9{BT@s`&PWYoR literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ua.png b/webapp/src/assets/flags/ua.png new file mode 100644 index 0000000000000000000000000000000000000000..fe466382c2a3be8dea93d9db4a87ce3515cf7f0a GIT binary patch literal 664 zcmV;J0%!e+P)myh0d&qjAFr98aq<_FsR}8OLrt8mUS%&U4p=v(MgouWPu-5$3@~KmaB%1M}eH z#OgKhb&%v_KQXt0vl3pF0E^7)Bw|vgVF(=c8j&|$r>h^rjzm;Se865DHDWcqkDYux zh>r*H9zw$l$T=W_AP3$tpu{&IH@2Dkic4qwF2#mX7?M|VHGq1vM2r){PZc5b?to$BesXWO1uTjd?LJP>j_MGimAfJPdh6S`&MJ;ar1B2o+)MDg~HAGS7$|gKgm&7TIGlBsJ zE}u(oTxTu;=R*iw(vG;E+@A6lY;7a+yo-?g_-99t;~idKxx?!Ip6rb$4=J4PWy;r? y-mWk$F#(Lq=$FwY@G?p=7R~rzRo>iuz_-6Uc{erLZefK00000_2TFE@$)xbgrpKQ z`uO>@97VfCV#;QK^xx&TCr-y&cHytQFj#;fOnA_6ip*|??8(ySzsc~{-N=oc8cB6S zXN>pg?7~uR>cr0d`un~-U(|q>&T56zdXo3+?!b79?9te&CPb=Xbd47_5k76-ps_1Z zci*SC-Ib~4w8FYdX4HzD$zOYd5G)lnOK#-o?$3Uazj23~L0Iq3*S);J!GMsyrL4Sefw`@)tx{^P zZh)REOV^vKww|PP3?o}GUK>Sha(bChb(m;gf?Z8h{jU z+}y(ai$itmzC zXfgniv0$Vrqk4iF!%A7`aj%j_lC z8#t^)sR*~LbDvYKblKjEC0W^SifyRiKfa%`{OOmk<*eGpg8zxbJGf%)T2_)W>DnNi zL`lXCg=D^OkD;h2CmGYss3{?cb>r4qb7;j2zs7Js;1Mg!Ods`QiE>(sHyY}h_U8_M z_EZJ?C#W>6p62HI2(j+t{WEk@>G~zZgcH@KBnq!*3b#*i!@HyF^V_^L`j$f|=i1sJ zltbv<)!ttfY0X(tgWxYUmr^3_(qcWCV4F_efJ+}CM$t6KaKeI_VyufCmmrUDtj;~elgV3(=K`A#KXfY zcKfYbTA z!jO4ebXS~&W<{rjZT|iK#*v6}uQ(ADz6$0ljZK#J;Ga?Dw=~!2ZqMw9fyX?B^>^L1n2%YC2o7@!}RUh=+fuSyrj`9#ib%5R!Gia+@OpgDE+ zD@a~kOUvb5BpWaAB*PB!)nsLAm*Qs~O997;Oh1lIRlUh^5m@wEN+4Ngv7A^9?bEdT%j M07*qoM6N<$g5fqZKL7v# literal 0 HcmV?d00001 diff --git a/webapp/src/assets/flags/ve.png b/webapp/src/assets/flags/ve.png new file mode 100644 index 0000000000000000000000000000000000000000..9e83fa72d8b48e4049b08dea06b6146ebc8b4b43 GIT binary patch literal 634 zcmV-=0)_pFP)k17@7;->Zkyl0WURvM)ogN)T=)OZ zeJQ(QF#kk&^Jqg>-_;z3Dp%O2+$6|qwF5=Jg(fEnxyAvupn*ibNIgy7*@6aE>FN0X zJDsSyx?Aq{lRxMCa%`@{hre`0mrXQYlNrQ1p%Yb4mfy<#;_Ma!Bps30H@`~N@pd)4 z8HglAN=Ia7lYxlj>ae>AjxX0IXX95tJ`#EHEWGPMi-Of+&1tuuUZz&PvI^1Mhszo@>vri;50rC^r zz;KO$;VT2f4-fhW{=M|En1Ohcf)y*cF)gJnEPVki^CGWofSYeM?GB}LnJPjUcAZIWFWwL zVcH^%1s&Q)w@s-HjJ)&z|LHZ{indfIT`3_@3mj0DE|HjSx%q|=>e&)d_;I6Z5 z$}z_l%lX;@idP@lu-qu!wI=gSQ^Uo*+6iwxKJohgVm}qkUdJtMcsqvaWn#7J$#pC0 z58jM9VOx2Nss8>6j+3j(re1e+6q;lyr15!?SD#SEkvHdrDwh0Xk2n7oBBDO)caE{x z0cU4d4!-I8q018_G30D9KSGqYd00~#QH!c7RUBN{=Z7EUD&CUgGO8^dK0192cLpifK zFTO!Gy+Ss~e|Vt4wgY@x1$+(0E%A$eo^@P`T&7e);288 zJ1_u+SsE#e=<@Ed)yM#1I`p_AQCy|Q;ME<4SUN_U=VxCZ7X$!MFBFVnzCAP5=HCDb zRxpoXc(Ii8|1`r_dCy(;zg_vEJ? z-c(9jsf;j#V0@W1&YK_PxF(;lRl+<7)6?UP>Mh1+}ut&3g~cX?1z9d zg+f3t2yRgg^ufHdrz{G7Ogoct*03eyTT?tP3U{fK@vf@0R!}G~Zlb8s%+TiOx3krr z8&r2Lc7Coe8|X{`0016zQchC$^K@rKoCF|D-Lz|cP%YmcejskmT=npc<-EHYFbUK|zgF?SqOVcz(O}y2Aj{YY8hJ|LsD}WHjN`aA! zrO>EeoJTv4(c#PN6a@&++VwkJS zuT~3XHWod?W=rJ-U}sOY_>Mxfv@8(_WkP{uNd$fYGd)p`xIk}M00000NkvXXu0mjf Dvw~la literal 0 HcmV?d00001 diff --git a/webapp/src/config.php b/webapp/src/config.php new file mode 100644 index 0000000..e9672ae --- /dev/null +++ b/webapp/src/config.php @@ -0,0 +1,48 @@ + \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_TIMEOUT => 5, + ]); + } catch (\PDOException $e) { + http_response_code(503); + header('Content-Type: application/json'); + echo json_encode(['error' => 'DB unavailable: ' . $e->getMessage()]); + exit; + } + return $pdo; +} + +// ── Helper : fetch tar1090 JSON ─────────────────────────────────────────────── +function tar1090_fetch(string $path = '/data/aircraft.json'): array { + $url = TAR1090_URL . $path; + $ctx = stream_context_create(['http' => ['timeout' => 4]]); + $json = @file_get_contents($url, false, $ctx); + return $json ? (json_decode($json, true) ?: []) : []; +} diff --git a/webapp/src/css/app.css b/webapp/src/css/app.css new file mode 100644 index 0000000..95c48b1 --- /dev/null +++ b/webapp/src/css/app.css @@ -0,0 +1,424 @@ +/* ── app.css — ADS-B Dashboard ─────────────────────────────────────────── */ + +:root { + --adsb-bg: #0d1117; + --adsb-surface: #161b22; + --adsb-border: #30363d; + --adsb-primary: #58a6ff; + --adsb-success: #3fb950; + --adsb-warning: #d29922; + --adsb-danger: #f85149; + --adsb-info: #79c0ff; + --adsb-text: #c9d1d9; + --adsb-muted: #6e7681; +} + +html, body { + height: 100%; + background-color: var(--adsb-bg); + color: var(--adsb-text); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + margin: 0; + overflow-x: hidden; +} + +/* ── Navbar ───────────────────────────────────────────────────────────────── */ +.navbar { min-height: 52px; } + +.live-dot { + display: inline-block; + width: 8px; height: 8px; + background: #3fb950; + border-radius: 50%; + animation: pulse 1.5s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── Tabs ─────────────────────────────────────────────────────────────────── */ +.nav-tabs-adsb { border-bottom: 1px solid var(--adsb-border); } +.nav-tabs-adsb .nav-link { + color: var(--adsb-muted); + border: none; + border-bottom: 2px solid transparent; + padding: 8px 16px; + font-size: 13px; +} +.nav-tabs-adsb .nav-link:hover { color: var(--adsb-text); } +.nav-tabs-adsb .nav-link.active { + color: var(--adsb-primary); + background: transparent; + border-bottom: 2px solid var(--adsb-primary); +} + +/* ── Carte tar1090 ────────────────────────────────────────────────────────── */ +.tar1090-iframe { + width: 100%; border: none; + height: calc(100vh - 104px); + display: block; +} + +/* ── Cards ────────────────────────────────────────────────────────────────── */ +.card { + background-color: var(--adsb-surface) !important; + border-color: var(--adsb-border) !important; +} +.card-header { + background-color: rgba(255,255,255,0.03); + border-bottom: 1px solid var(--adsb-border); + font-size: 13px; font-weight: 600; padding: 8px 12px; +} +.fs-1 { line-height: 1.1; } +.smaller { font-size: 11px; } + +/* ── Graphiques ───────────────────────────────────────────────────────────── */ +.chart-container { position: relative; height: 260px; } +.chart-container-lg { position: relative; height: 320px; } +.chart-container-sm { position: relative; width: 220px; height: 220px; } + +/* ── Tableaux ─────────────────────────────────────────────────────────────── */ +.table-scroll { max-height: 320px; overflow-y: auto; } +.table-scroll-sm { max-height: 260px; overflow-y: auto; } +.table-dark { + --bs-table-bg: transparent; + --bs-table-hover-bg: rgba(88,166,255,0.06); +} +.table-dark th { + font-size: 11px; font-weight: 600; + text-transform: uppercase; color: var(--adsb-muted); + border-bottom: 1px solid var(--adsb-border); +} +.table-dark td { + font-size: 12px; + border-color: rgba(48,54,61,0.5); + vertical-align: middle; +} +.table-header-sticky th { + position: sticky; top: 0; + background-color: var(--adsb-surface); z-index: 1; +} + +/* ── Badges type opérateur ────────────────────────────────────────────────── */ +.badge-op { + font-size: 10px; padding: 2px 6px; + border-radius: 3px; font-weight: 600; + text-transform: uppercase; +} +.badge-civil { background: rgba(63,185,80,0.2); color: #3fb950; } +.badge-military { background: rgba(248,81,73,0.2); color: #f85149; } +.badge-cargo { background: rgba(210,153,34,0.2); color: #d29922; } +.badge-emergency { background: rgba(255,121,198,0.2); color: #ff79c6; } +.badge-government { background: rgba(121,192,255,0.2); color: #79c0ff; } +.badge-private { background: rgba(139,148,158,0.2); color: #8b949e; } +.badge-unknown { background: rgba(110,118,129,0.15);color: #6e7681; } + +/* Badges type aéronef */ +.badge-airplane { background: rgba(88,166,255,0.2); color: #58a6ff; } +.badge-helicopter { background: rgba(188,140,255,0.2); color: #bc8cff; } +.badge-drone { background: rgba(255,166,87,0.2); color: #ffa657; } +.badge-glider { background: rgba(63,185,80,0.15); color: #3fb950; } +.badge-balloon { background: rgba(210,153,34,0.2); color: #d29922; } + +/* ── KPI catégories ───────────────────────────────────────────────────────── */ +.cat-kpi-card { + background: var(--adsb-surface); + border: 1px solid var(--adsb-border); + border-radius: 8px; + padding: 10px 14px; + text-align: center; + min-width: 100px; +} +.cat-kpi-value { font-size: 1.6rem; font-weight: 700; line-height: 1.1; } +.cat-kpi-label { font-size: 11px; color: var(--adsb-muted); margin-top: 2px; } + +/* ── RSSI ─────────────────────────────────────────────────────────────────── */ +.rssi-strong { color: #3fb950; } +.rssi-medium { color: #d29922; } +.rssi-weak { color: #f85149; } + +/* ── ICAO link ────────────────────────────────────────────────────────────── */ +.icao-link { + font-family: 'Courier New', monospace; + font-size: 11px; color: var(--adsb-primary); + text-decoration: none; font-weight: 600; +} +.icao-link:hover { text-decoration: underline; } + +/* ── Scrollbar dark ───────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--adsb-bg); } +::-webkit-scrollbar-thumb { background: var(--adsb-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--adsb-muted); } + +@media (max-width: 768px) { + .tar1090-iframe { height: calc(100vh - 120px); } + .chart-container-lg { height: 240px; } + .fs-1 { font-size: 1.8rem !important; } +} + +/* ── Carte Île-de-France ──────────────────────────────────────────────────── */ +#map-idf { + height: calc(100vh - 148px); + width: 100%; + background: #0d1117; +} + +.carte-idf-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + background: var(--adsb-surface); + border-bottom: 1px solid var(--adsb-border); +} + +.traj-legend { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--adsb-muted); +} +.traj-legend span { + display: inline-block; + width: 18px; + height: 3px; + border-radius: 2px; +} + +/* Popup Leaflet dark mode */ +.leaflet-popup-content-wrapper { + background: #1c2128 !important; + color: #c9d1d9 !important; + border: 1px solid #30363d !important; + border-radius: 6px !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important; +} +.leaflet-popup-tip { background: #1c2128 !important; } +.leaflet-popup-content { margin: 10px 14px !important; font-size: 12px; } +.leaflet-popup-content b { color: #58a6ff; } +.popup-icao { font-family: monospace; font-size: 13px; font-weight: bold; color: #58a6ff; } +.popup-row { display: flex; justify-content: space-between; gap: 12px; margin: 3px 0; } +.popup-label { color: #6e7681; } +.popup-val { color: #c9d1d9; font-weight: 600; } + +/* Icône avion SVG */ +.plane-icon { transition: transform 0.3s ease; } + +/* Aéroports */ +.airport-label { + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +@media (max-width: 768px) { + #map-idf { height: calc(100vh - 200px); } + .carte-idf-toolbar { flex-direction: column; align-items: flex-start; } +} + +/* ── Panneau latéral carte IDF ───────────────────────────────────────────── */ +.carte-idf-body { + display: flex; + height: calc(100vh - 148px); +} + +#map-idf { + flex: 1; + height: 100%; + background: #0d1117; +} + +.carte-idf-panel { + width: 200px; + min-width: 200px; + background: var(--adsb-surface); + border-left: 1px solid var(--adsb-border); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.panel-section { + padding: 10px 12px; + border-bottom: 1px solid var(--adsb-border); +} + +.panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + color: var(--adsb-muted); + margin-bottom: 8px; + letter-spacing: 0.5px; +} + +.panel-check { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--adsb-text); + cursor: pointer; + padding: 3px 0; + user-select: none; +} +.panel-check:hover { color: #fff; } +.panel-check input[type="checkbox"] { + width: 14px; height: 14px; + accent-color: var(--adsb-primary); + cursor: pointer; + margin: 0; +} +.check-dot { + display: inline-block; + width: 10px; height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +/* Boutons unités */ +.unit-toggle { + display: flex; + flex-direction: column; + gap: 6px; +} +.unit-btn { + background: var(--adsb-bg); + border: 1px solid var(--adsb-border); + color: var(--adsb-muted); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + cursor: pointer; + text-align: left; + line-height: 1.4; + transition: all 0.15s; +} +.unit-btn:hover { + border-color: var(--adsb-primary); + color: var(--adsb-text); +} +.unit-btn.active { + background: rgba(88,166,255,0.12); + border-color: var(--adsb-primary); + color: var(--adsb-primary); +} +.unit-btn small { color: var(--adsb-muted); font-size: 10px; } +.unit-btn.active small { color: var(--adsb-primary); opacity: 0.7; } + +/* Compteurs */ +.panel-stats { display: flex; flex-direction: column; gap: 4px; } +.stat-row { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--adsb-text); +} +.stat-row span:last-child { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +/* Filtres actifs/inactifs */ +.panel-check input:not(:checked) ~ .check-dot { opacity: 0.3; } +.panel-check input:not(:checked) + .check-dot + span, +.panel-check:has(input:not(:checked)) { opacity: 0.5; } + +@media (max-width: 768px) { + .carte-idf-body { flex-direction: column; } + .carte-idf-panel { width: 100%; min-width: unset; border-left: none; border-top: 1px solid var(--adsb-border); } + #map-idf { height: 50vh; } +} + +/* ── Carte Historique ─────────────────────────────────────────────────────── */ +#map-hist { + flex: 1; + height: 100%; + background: #0d1117; +} + +.hist-loading { + position: relative; + z-index: 10; +} + +/* Input datetime dark */ +input[type="datetime-local"].form-control { + color-scheme: dark; +} + +/* ── Onglet État du stack ────────────────────────────────────────────────── */ +.status-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background: var(--adsb-surface); + border-bottom: 1px solid var(--adsb-border); + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} + +#tab-status { + display: flex; + flex-direction: column; + width: 100%; + height: calc(100vh - 130px); + min-height: 400px; + position: relative; +} + +.status-iframe { + position: absolute; + top: 0; + left: 0; + width: 100% !important; + height: 100% !important; + border: none; + background: #0d1117; + display: block; +} + +/* wrapper pour l'iframe dans l'onglet */ +.status-iframe-wrapper { + flex: 1; + position: relative; + width: 100%; + min-height: 0; + overflow: hidden; +} + +/* Mode plein écran */ +#tab-status.status-fullscreen { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 9999; + height: 100vh; + background: #0d1117; +} +#tab-status.status-fullscreen .status-toolbar { + padding: 4px 12px; +} +#tab-status.status-fullscreen .status-iframe { + flex: 1; + height: auto; +} + +@media (max-width: 768px) { + #tab-status { height: calc(100vh - 100px); } +} + +/* ── Onglet État du stack ─────────────────────────────────────────────────── */ +#tab-status { + height: calc(100vh - 104px); + display: flex; + flex-direction: column; + overflow: hidden; +} diff --git a/webapp/src/deploiement.sh b/webapp/src/deploiement.sh new file mode 100755 index 0000000..cb765a5 --- /dev/null +++ b/webapp/src/deploiement.sh @@ -0,0 +1,34 @@ +echo "" +# +# +# +node --check js/app.js +# +for f in api.php index.php js/app.js; do + docker cp "$f" "adsb-webapp:/var/www/html/$f" +done + +for f in api.php index.php js/app.js; do + docker sha256sum "adsb-webapp:/var/www/html/$f" +done + +echo "" +echo "docker exec adsb-webapp chown -R www-data:www-data /var/www/html" +docker exec adsb-webapp chown -R www-data:www-data /var/www/html +echo "docker exec adsb-webapp chown -R www-data:www-data /var/www/html" +# +# +# +echo "" +echo "docker exec adsb-webapp sha256sum /var/www/html/index.php" +docker exec adsb-webapp sha256sum /var/www/html/index.php +echo "docker exec adsb-webapp sha256sum /var/www/html/api.php" +docker exec adsb-webapp sha256sum /var/www/html/api.php +echo "docker exec adsb-webapp sha256sum /var/www/html/js/app.js" +docker exec adsb-webapp sha256sum /var/www/html/js/app.js +echo "" +echo "docker cp /data/adsb/watchlist_schema.sql wordpress-postgres:/tmp/" +docker cp /data/adsb/watchlist_schema.sql wordpress-postgres:/tmp/ +echo "docker exec wordpress-postgres psql -v ON_ERROR_STOP=1 -U pgz_admin -d wpz_postgres -f /tmp/watchlist_schema.sql" +docker exec wordpress-postgres psql -v ON_ERROR_STOP=1 \ + -U pgz_admin -d wpz_postgres -f /tmp/watchlist_schema.sql diff --git a/webapp/src/index.php b/webapp/src/index.php new file mode 100644 index 0000000..3cd0d0a --- /dev/null +++ b/webapp/src/index.php @@ -0,0 +1,1681 @@ + + + + + + +<?= htmlspecialchars(APP_TITLE) ?> + + + + + + + + + + +

    + + + + + +
    +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Avions en vue
    +
    maintenant
    +
    +
    +
    +
    +
    +
    +
    +
    Sessions aujourd'hui
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Sessions (période)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Messages reçus
    +
    depuis démarrage
    +
    +
    +
    +
    + + +
    +
    + Affluence — avions par créneau + +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + Avions en vue + 0 +
    +
    + + + + + + + + + + + +
    ICAOIndicatifTypeOpérateurAlt ftVit ktPaysRSSI
    +
    Chargement… +
    +
    +
    +
    +
    +
    +
    + Derniers passages + 0 +
    +
    + + + + + + + + + + + +
    ICAOIndicatifOpérateurEntréeSortieDuréeDépartArrivéeAlt moy
    +
    Chargement… +
    +
    +
    +
    +
    + + +
    +
    +
    +
    Top compagnies
    +
    + + + + + + + +
    OpérateurPaysTypeSessionsDurée moy
    +
    +
    +
    +
    +
    +
    Par type d'opérateur
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    Par pays d'immatriculation
    +
    + + + + + + + +
    PaysSessions%
    +
    +
    +
    +
    + +
    +
    + + +
    + +
    +
    + +
    + Liste + +
    + +
    + Depuis + +
    + +
    + Période + +
    + +
    + Sources + +
    + + + + 0 appareil(s) + + +
    +
    + +
    +
    + + Les appareils sont suivis par leur adresse ICAO 24 bits, stable + pour une cellule donnée, et non par leur indicatif, qui change à chaque vol. + Les colonnes Départs et Dernier contact portent sur la période + sélectionnée ci-dessus. +
    + +
    + + + + + + + + + + + + + + + + + + + + +
    ICAOIndicatifTypeCat.ExploitantPaysListeMotifSous surveillance depuisDépartsDernier contact
    + Chargement… +
    +
    + + +
    +
    +
    + + + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    + +
    + + +
    +
    + Affluence globale par créneau +
    + +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    + Présence par type d'opérateur (sessions simultanées) +
    +
    +
    + +
    +
    +
    + + +
    +
    + Présence par type d'aéronef +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    Détail par type d'opérateur
    +
    + + + + + + + + + + +
    TypeSessionsAvions distinctsDurée moyAlt moy%
    +
    +
    +
    +
    +
    +
    Détail par type d'aéronef
    +
    + + + + + + + + + + +
    Type aéronefSessionsAvions distinctsAlt moyVitesse moy%
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + Trajectoires + +
    + + 0 avion(s) + 0 visibles + +
    +
    + Altitude : + <3000ft + <8000ft + <15000ft + <25000ft + <35000ft + >35000ft +
    +
    +
    +
    +
    + +
    +
    Type opérateur
    + + + + + + + +
    + +
    +
    Type aéronef
    + + + + + + +
    + +
    +
    Unités
    +
    + + +
    +
    + +
    +
    Compteurs
    +
    +
    Total
    +
    Visibles
    +
    Civil
    +
    Militaire
    +
    Cargo
    +
    Secours
    +
    Gouv.
    +
    Privé
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    + Depuis + +
    + + +
    + Fenêtre + +
    + + + + + 0 avion(s) + 0 visibles + +
    +
    + Altitude : + <3000ft + <8000ft + <15000ft + <25000ft + <35000ft + >35000ft +
    +
    + + +
    +
    +
    +
    +
    Chargement…
    +
    + +
    +
    + + +
    + +
    +
    Type opérateur
    + + + + + + + +
    + +
    +
    Type aéronef
    + + + + + + +
    + +
    +
    Unités
    +
    + + +
    +
    + +
    +
    Compteurs
    +
    +
    Total
    +
    Visibles
    +
    Civil
    +
    Militaire
    +
    Cargo
    +
    Secours
    +
    Gouv.
    +
    Privé
    +
    +
    + +
    +
    Agrégation
    +
    +
    Stratégie
    +
    De
    +
    À
    +
    +
    + +
    +
    +
    + + + + + +
    +
    +
    + +
    + Depuis + +
    + +
    + Période + +
    + +
    + Alt. max + +
    + +
    + Montée ≥ + +
    + +
    + Marge + +
    + + +
    + Sources + +
    + + + + 0 départ(s) + résultats tronqués +
    +
    + +
    +
    + + Détection des montées initiales situées hors du rayon des aérodromes connus. + Un appareil décollant à 40 km reste masqué par l'horizon radio jusqu'à ~1500 ft : + le point indiqué est le plus bas capté, pas nécessairement le point de décollage. + Plus la distance à la station est grande, plus l'écart est important. +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Date / heureOcc.ICAOIndicatifTypeCat.PaysExploitantAlt.MontéePhaseVit.PositionAéro. procheStationSource
    + Choisis une période puis clique sur Rechercher +
    +
    + + +
    +
    +
    + + + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + Depuis + +
    + +
    + Période + +
    + +
    + Maille + +
    + +
    + Min. évts + +
    + +
    + Alt. max + +
    + +
    + Marge + +
    + + +
    + Sources + +
    + + + + 0 zone(s) + +
    + + +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    + Zone + + + +
    +
    + + + + + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    Date / heureOcc.ICAOIndicatifTypeCat.ExploitantPaysAlt.MontéePhaseVit.CapSquawkPositionSource
    +
    + + +
    +
    +
    + + + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + + +
    + +
    + Bilan par zone + +
    + + +
    + Aéroport civil + Militaire + Aérodrome + Héliport + Hors zone +
    + +
    +
    Lance une analyse pour afficher le bilan.
    +
    + + +
    + +
    +
    + Zones découvertes les plus actives +
    +
    +
    Lance une analyse pour afficher les zones
    +
    +
    + Clique une zone sur la carte puis + « Exclure cette zone » pour l'ajouter à la liste d'exclusion. +
    +
    +
    +
    +
    + +
    +
    + + + Rapport généré par check_adsb.sh — actualisation auto 60s + +
    + + +
    +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + diff --git a/webapp/src/js/app.js b/webapp/src/js/app.js new file mode 100644 index 0000000..17098c2 --- /dev/null +++ b/webapp/src/js/app.js @@ -0,0 +1,4376 @@ +'use strict'; + +// ── État global ─────────────────────────────────────────────────────────────── +const STATE = { period: 24, granularity: 60, charts: {}, timers: {} }; + +// ── Couleurs ────────────────────────────────────────────────────────────────── +const C = { + primary: '#58a6ff', success: '#3fb950', warning: '#d29922', + danger: '#f85149', info: '#79c0ff', purple: '#bc8cff', + orange: '#ffa657', pink: '#ff79c6', muted: '#6e7681', + teal: '#39d353', +}; + +const OP_TYPE_COLORS = { + civil: C.success, military: C.danger, + cargo: C.warning, emergency: C.pink, + government: C.info, private: C.muted, + unknown: '#3d3d3d', +}; + +const AC_TYPE_COLORS = { + airplane: C.primary, helicopter: C.purple, + drone: C.orange, glider: C.teal, + balloon: C.warning, unknown: '#3d3d3d', +}; + +const OP_TYPE_LABELS = { + civil:'Civil', military:'Militaire', cargo:'Cargo', + emergency:'Secours', government:'Gouvernement', private:'Privé', unknown:'Inconnu', +}; +const AC_TYPE_LABELS = { + airplane:'Avion', helicopter:'Hélicoptère', drone:'Drone', + glider:'Planeur', balloon:'Ballon', unknown:'Inconnu', +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +async function apiFetch(action, extra = {}) { + const p = new URLSearchParams({ action, period: STATE.period, granularity: STATE.granularity, ...extra }); + const r = await fetch(`/api.php?${p}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +const fmt = (v, u='') => (v === null || v === undefined || v === '') ? '—' + : Number(v).toLocaleString('fr-FR') + (u ? '\u00a0'+u : ''); + +const fmtDur = s => { + if (!s) return '—'; + const n = parseInt(s); + if (n < 60) return `${n}s`; + if (n < 3600) return `${Math.floor(n/60)}min`; + return `${Math.floor(n/3600)}h${String(Math.floor((n%3600)/60)).padStart(2,'0')}`; +}; + +const esc = s => !s ? '' : String(s) + .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + +const rssiCls = v => !v ? '' : parseFloat(v) > -20 ? 'rssi-strong' : parseFloat(v) > -30 ? 'rssi-medium' : 'rssi-weak'; + +const opBadge = t => { + const k = (t||'unknown').toLowerCase(); + return `${OP_TYPE_LABELS[k]||k}`; +}; +const acBadge = t => { + const k = (t||'unknown').toLowerCase(); + return `${AC_TYPE_LABELS[k]||k}`; +}; + +// ── Chart.js config commune ─────────────────────────────────────────────────── +const commonOptions = (stacked = false) => ({ + responsive: true, maintainAspectRatio: false, + animation: { duration: 300 }, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { labels: { color: '#8b949e', font: { size: 11 }, boxWidth: 12, padding: 8 } }, + tooltip: { + backgroundColor: '#1c2128', borderColor: '#30363d', borderWidth: 1, + titleColor: '#c9d1d9', bodyColor: '#8b949e', + }, + }, + scales: { + x: { + stacked, + ticks: { color: '#6e7681', maxRotation: 45, autoSkip: true, maxTicksLimit: 16, font: { size: 11 } }, + grid: { color: 'rgba(48,54,61,0.5)' }, + }, + y: { + stacked, + beginAtZero: true, + ticks: { color: '#6e7681', precision: 0, font: { size: 11 } }, + grid: { color: 'rgba(48,54,61,0.5)' }, + }, + }, +}); + +// ── Horloge ─────────────────────────────────────────────────────────────────── +function startClock() { + const tick = () => { document.getElementById('clock').textContent = new Date().toLocaleTimeString('fr-FR'); }; + tick(); setInterval(tick, 1000); +} + +// ── LIVE ────────────────────────────────────────────────────────────────────── +async function loadLive() { + try { + const d = await apiFetch('live'); + const ac = d.aircraft || []; + document.getElementById('live-count').textContent = ac.length; + document.getElementById('msg-count').textContent = Number(d.messages||0).toLocaleString('fr-FR'); + document.getElementById('live-table-count').textContent = ac.length; + document.getElementById('kpi-live').textContent = ac.length; + document.getElementById('kpi-messages').textContent = Number(d.messages||0).toLocaleString('fr-FR'); + + const tbody = document.getElementById('live-tbody'); + if (!ac.length) { tbody.innerHTML = 'Aucun avion en vue'; return; } + tbody.innerHTML = ac.map(a => { + const icao = (a.hex||'').toUpperCase(); + const alt = a.alt_baro ?? a.altitude; + return ` + ${esc(icao)} + ${esc((a.flight||'').trim())||'—'} + ${esc(a.t||'—')} + ${esc(a.desc||a.ownOp||'—')} + ${fmt(alt)} + ${fmt(a.gs??a.spd)} + ${esc(a.cou||'—')} + ${a.rssi ? parseFloat(a.rssi).toFixed(1) : '—'} + `; + }).join(''); + } catch(e) { console.error('loadLive:', e); } +} + +// ── KPI ─────────────────────────────────────────────────────────────────────── +async function loadKPI() { + try { + const d = await apiFetch('kpi'); + document.getElementById('kpi-today-sess').textContent = Number(d.today_sess||0).toLocaleString('fr-FR'); + document.getElementById('kpi-today-avions').textContent = `${Number(d.today_avions||0).toLocaleString('fr-FR')} avions distincts`; + document.getElementById('kpi-period-sess').textContent = Number(d.period_sess||0).toLocaleString('fr-FR'); + document.getElementById('kpi-period-avions').textContent= `${Number(d.period_avions||0).toLocaleString('fr-FR')} avions | ${d.period_h}h`; + } catch(e) { console.error('loadKPI:', e); } +} + +// ── Courbe affluence globale ─────────────────────────────────────────────────── +async function loadAffluence(canvasId = 'affluence-chart', rangeId = 'chart-range') { + try { + const d = await apiFetch('affluence'); + const slots = d.slots || []; + const labels = slots.map(s => s.label); + const avions = slots.map(s => parseInt(s.nb_avions)||0); + const sessions = slots.map(s => parseInt(s.nb_sessions)||0); + + const first = slots[0]?.label||'', last = slots[slots.length-1]?.label||''; + const el = document.getElementById(rangeId); + if (el) el.textContent = first && last ? `${first} → ${last}` : ''; + + const ctx = document.getElementById(canvasId).getContext('2d'); + if (STATE.charts[canvasId]) { + STATE.charts[canvasId].data.labels = labels; + STATE.charts[canvasId].data.datasets[0].data = avions; + STATE.charts[canvasId].data.datasets[1].data = sessions; + STATE.charts[canvasId].update('none'); return; + } + STATE.charts[canvasId] = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { label:'Avions distincts', data:avions, borderColor:C.primary, backgroundColor:'rgba(88,166,255,0.12)', fill:true, tension:0.35, pointRadius:2, pointHoverRadius:5 }, + { label:'Sessions', data:sessions,borderColor:C.success, backgroundColor:'rgba(63,185,80,0.08)', fill:true, tension:0.35, pointRadius:2, pointHoverRadius:5, borderDash:[4,3] }, + ], + }, + options: commonOptions(), + }); + } catch(e) { console.error('loadAffluence:', e); } +} + +// ── Courbe par type d'opérateur ─────────────────────────────────────────────── +async function loadAffluenceByOpType() { + try { + const d = await apiFetch('affluence_by_optype'); + const rows = d.rows || []; + if (!rows.length) return; + + // Pivoter : labels (créneaux) × séries (types) + const labelsMap = {}, typeSet = new Set(); + rows.forEach(r => { labelsMap[r.label] = labelsMap[r.label]||{}; typeSet.add(r.op_type); labelsMap[r.label][r.op_type] = parseInt(r.nb_avions)||0; }); + const labels = Object.keys(labelsMap); + const types = [...typeSet]; + + const isStacked = document.getElementById('stack-toggle')?.checked ?? true; + const ctx = document.getElementById('cat-bytype-chart').getContext('2d'); + if (STATE.charts['cat-bytype-chart']) { + STATE.charts['cat-bytype-chart'].data.labels = labels; + STATE.charts['cat-bytype-chart'].data.datasets = types.map(t => ({ + label: OP_TYPE_LABELS[t]||t, + data: labels.map(l => labelsMap[l][t]||0), + borderColor: OP_TYPE_COLORS[t]||C.muted, + backgroundColor: (OP_TYPE_COLORS[t]||C.muted)+'33', + fill: isStacked, tension: 0.3, pointRadius: 2, pointHoverRadius: 5, + })); + STATE.charts['cat-bytype-chart'].options.scales.x.stacked = isStacked; + STATE.charts['cat-bytype-chart'].options.scales.y.stacked = isStacked; + STATE.charts['cat-bytype-chart'].update('none'); return; + } + STATE.charts['cat-bytype-chart'] = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: types.map(t => ({ + label: OP_TYPE_LABELS[t]||t, + data: labels.map(l => labelsMap[l][t]||0), + borderColor: OP_TYPE_COLORS[t]||C.muted, + backgroundColor: (OP_TYPE_COLORS[t]||C.muted)+'33', + fill: isStacked, tension: 0.3, pointRadius: 2, pointHoverRadius: 5, + })), + }, + options: commonOptions(isStacked), + }); + } catch(e) { console.error('loadAffluenceByOpType:', e); } +} + +// ── Courbe par type d'aéronef ───────────────────────────────────────────────── +async function loadAffluenceByAircraft() { + try { + const d = await apiFetch('affluence_by_aircraft'); + const rows = d.rows || []; + if (!rows.length) return; + + const labelsMap = {}, typeSet = new Set(); + rows.forEach(r => { labelsMap[r.label] = labelsMap[r.label]||{}; typeSet.add(r.ac_type); labelsMap[r.label][r.ac_type] = parseInt(r.nb_avions)||0; }); + const labels = Object.keys(labelsMap); + const types = [...typeSet]; + + const ctx = document.getElementById('cat-byaircraft-chart').getContext('2d'); + if (STATE.charts['cat-byaircraft-chart']) { + STATE.charts['cat-byaircraft-chart'].data.labels = labels; + STATE.charts['cat-byaircraft-chart'].data.datasets = types.map(t => ({ + label: AC_TYPE_LABELS[t]||t, + data: labels.map(l => labelsMap[l][t]||0), + borderColor: AC_TYPE_COLORS[t]||C.muted, + backgroundColor: (AC_TYPE_COLORS[t]||C.muted)+'33', + fill: false, tension: 0.3, pointRadius: 2, pointHoverRadius: 5, + })); + STATE.charts['cat-byaircraft-chart'].update('none'); return; + } + STATE.charts['cat-byaircraft-chart'] = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: types.map(t => ({ + label: AC_TYPE_LABELS[t]||t, + data: labels.map(l => labelsMap[l][t]||0), + borderColor: AC_TYPE_COLORS[t]||C.muted, + backgroundColor: (AC_TYPE_COLORS[t]||C.muted)+'33', + fill: false, tension: 0.3, pointRadius: 2, pointHoverRadius: 5, + })), + }, + options: commonOptions(false), + }); + } catch(e) { console.error('loadAffluenceByAircraft:', e); } +} + +// ── KPI catégories ──────────────────────────────────────────────────────────── +async function loadCatKPI() { + try { + const [opd, acd] = await Promise.all([ + apiFetch('stats_by_optype'), + apiFetch('stats_by_aircraft'), + ]); + const opRows = opd.rows || []; + const acRows = acd.rows || []; + + // KPI par type d'opérateur + const kpiRow = document.getElementById('cat-kpi-row'); + kpiRow.innerHTML = ''; + + opRows.forEach(r => { + const t = r.type_op||'unknown'; + const col = document.createElement('div'); + col.className = 'col-auto'; + col.innerHTML = `
    +
    ${r.sessions}
    +
    ${OP_TYPE_LABELS[t]||t}
    +
    ${r.avions} avion(s)
    +
    `; + kpiRow.appendChild(col); + }); + + // Tableau type d'opérateur + const opTbody = document.getElementById('cat-optype-tbody'); + const opTotal = opd.total || 1; + opTbody.innerHTML = opRows.map(r => { + const t = r.type_op||'unknown'; + const pct = parseFloat(r.pct)||0; + return ` + ${opBadge(t)} + ${fmt(r.sessions)} + ${fmt(r.avions)} + ${r.duree_moy_min ? r.duree_moy_min+' min' : '—'} + ${fmt(r.alt_moy)} + +
    +
    +
    +
    + ${pct}% +
    + + `; + }).join('') || 'Aucune donnée'; + + // Tableau type d'aéronef + const acTbody = document.getElementById('cat-aircraft-tbody'); + acTbody.innerHTML = acRows.map(r => { + const t = r.type_ac||'unknown'; + const pct = parseFloat(r.pct)||0; + return ` + ${acBadge(t)} + ${fmt(r.sessions)} + ${fmt(r.avions)} + ${fmt(r.alt_moy)} + ${fmt(r.vit_moy)} + +
    +
    +
    +
    + ${pct}% +
    + + `; + }).join('') || 'Aucune donnée'; + + } catch(e) { console.error('loadCatKPI:', e); } +} + +// ── Dernières sessions ──────────────────────────────────────────────────────── +async function loadHistory() { + try { + const d = await apiFetch('history', { limit: 100 }); + const sessions = d.sessions || []; + document.getElementById('history-count').textContent = sessions.length; + const tbody = document.getElementById('history-tbody'); + if (!sessions.length) { tbody.innerHTML = 'Aucune session sur la période'; return; } + tbody.innerHTML = sessions.map(s => { + const icao = (s.icao||'').toUpperCase(); + return ` + ${esc(icao)} + ${esc(s.callsign||'—')} + ${esc(s.operator_name||'—')} + ${esc(s.entree||'—')} + ${esc(s.sortie||'—')} + ${fmtDur(s.duration_seconds)} + ${esc(s.origin||'—')} + ${esc(s.destination||'—')} + ${fmt(s.alt_avg)} + `; + }).join(''); + } catch(e) { console.error('loadHistory:', e); } +} + +// ── Top opérateurs ──────────────────────────────────────────────────────────── +async function loadTopOperators() { + try { + const d = await apiFetch('top_operators'); + const ops = d.operators||[]; + const tbody = document.getElementById('operators-tbody'); + if (!ops.length) { tbody.innerHTML = '—'; return; } + const max = parseInt(ops[0].sessions)||1; + tbody.innerHTML = ops.map((o,i) => { + const pct = Math.round(parseInt(o.sessions)/max*100); + return ` +
    + ${i+1} +
    ${esc(o.operateur)}
    +
    +
    +
    + ${esc(o.pays||'—')} + ${opBadge(o.type_op)} + ${fmt(o.sessions)} + ${o.duree_moy_min ? o.duree_moy_min+' min' : '—'} + `; + }).join(''); + } catch(e) { console.error('loadTopOperators:', e); } +} + +// ── Donut par type d'opérateur ──────────────────────────────────────────────── +async function loadByType() { + try { + const d = await apiFetch('by_type'); + const types = d.types||[]; + if (!types.length) return; + const labels = types.map(t => OP_TYPE_LABELS[t.type_op]||t.type_op); + const values = types.map(t => parseInt(t.sessions)||0); + const colors = types.map(t => OP_TYPE_COLORS[t.type_op]||C.muted); + const ctx = document.getElementById('type-chart').getContext('2d'); + if (STATE.charts['type-chart']) { + STATE.charts['type-chart'].data.labels = labels; + STATE.charts['type-chart'].data.datasets[0].data = values; + STATE.charts['type-chart'].data.datasets[0].backgroundColor = colors; + STATE.charts['type-chart'].update('none'); return; + } + STATE.charts['type-chart'] = new Chart(ctx, { + type: 'doughnut', + data: { labels, datasets: [{ data:values, backgroundColor:colors, borderWidth:2, borderColor:'#161b22', hoverOffset:6 }] }, + options: { + responsive:true, maintainAspectRatio:false, cutout:'62%', animation:{duration:400}, + plugins: { + legend: { position:'bottom', labels:{color:'#8b949e',font:{size:11},padding:8,boxWidth:12} }, + tooltip: { backgroundColor:'#1c2128',borderColor:'#30363d',borderWidth:1,titleColor:'#c9d1d9',bodyColor:'#8b949e', + callbacks:{label: ctx => ` ${ctx.parsed} sessions`} }, + }, + }, + }); + } catch(e) { console.error('loadByType:', e); } +} + +// ── Top pays ────────────────────────────────────────────────────────────────── +async function loadTopCountries() { + try { + const d = await apiFetch('top_countries'); + const rows = d.countries||[]; + const tbody = document.getElementById('countries-tbody'); + if (!rows.length) { tbody.innerHTML = '—'; return; } + tbody.innerHTML = rows.map(r => { + const pct = parseFloat(r.pct)||0; + return ` +
    ${esc(r.pays)}
    +
    +
    +
    + ${fmt(r.sessions)} + ${pct}% + `; + }).join(''); + } catch(e) { console.error('loadTopCountries:', e); } +} + +// ── Timestamp ───────────────────────────────────────────────────────────────── +function updateTimestamp() { + document.querySelectorAll('.last-update').forEach(el => { + el.textContent = 'Mis à jour : ' + new Date().toLocaleTimeString('fr-FR'); + }); +} + +// ── Chargement onglet Données ───────────────────────────────────────────────── +async function loadDonnees() { + await Promise.allSettled([ + loadKPI(), loadAffluence(), loadHistory(), + loadTopOperators(), loadByType(), loadTopCountries(), + ]); + updateTimestamp(); +} + +// ── Chargement onglet Catégories ────────────────────────────────────────────── +async function loadCategories() { + await Promise.allSettled([ + loadCatKPI(), + loadAffluence('cat-affluence-chart', 'cat-chart-range'), + loadAffluenceByOpType(), + loadAffluenceByAircraft(), + ]); + updateTimestamp(); +} + +// ── Timers ──────────────────────────────────────────────────────────────────── +function startTimers() { + STATE.timers.live = setInterval(loadLive, 5000); + STATE.timers.data = setInterval(() => { + const active = document.querySelector('#mainTabs .nav-link.active')?.getAttribute('href'); + if (active === '#tab-donnees') loadDonnees(); + if (active === '#tab-categories') loadCategories(); + }, 30000); +} + +// ── Init ────────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + startClock(); + loadLive(); + + // Synchroniser period/granularity entre onglets + document.querySelectorAll('.period-select').forEach(sel => { + sel.addEventListener('change', e => { + STATE.period = parseInt(e.target.value); + document.querySelectorAll('.period-select').forEach(s => s.value = STATE.period); + const active = document.querySelector('#mainTabs .nav-link.active')?.getAttribute('href'); + if (active === '#tab-donnees') loadDonnees(); + if (active === '#tab-categories') loadCategories(); + }); + }); + + document.querySelectorAll('.granularity-select').forEach(sel => { + sel.addEventListener('change', e => { + STATE.granularity = parseInt(e.target.value); + document.querySelectorAll('.granularity-select').forEach(s => s.value = STATE.granularity); + const active = document.querySelector('#mainTabs .nav-link.active')?.getAttribute('href'); + if (active === '#tab-donnees') loadAffluence(); + if (active === '#tab-categories') { loadAffluence('cat-affluence-chart','cat-chart-range'); loadAffluenceByOpType(); loadAffluenceByAircraft(); } + }); + }); + + document.querySelectorAll('.refresh-btn').forEach(btn => { + btn.addEventListener('click', () => { + loadLive(); + const active = document.querySelector('#mainTabs .nav-link.active')?.getAttribute('href'); + if (active === '#tab-donnees') loadDonnees(); + if (active === '#tab-categories') loadCategories(); + }); + }); + + // Toggle empilé/non empilé + document.getElementById('stack-toggle')?.addEventListener('change', () => { + if (STATE.charts['cat-bytype-chart']) { + STATE.charts['cat-bytype-chart'].destroy(); + delete STATE.charts['cat-bytype-chart']; + } + loadAffluenceByOpType(); + }); + + // Charger au changement d'onglet + document.querySelectorAll('#mainTabs .nav-link').forEach(tab => { + tab.addEventListener('shown.bs.tab', e => { + const href = e.target.getAttribute('href'); + if (href === '#tab-donnees') loadDonnees(); + if (href === '#tab-categories') loadCategories(); + }); + }); + + startTimers(); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CARTE ÎLE-DE-FRANCE — Leaflet + CartoDB Dark + trajectoires PostgreSQL +// ═══════════════════════════════════════════════════════════════════════════ + +const MAP_STATE = { + map: null, + layers: {}, // icao → { marker, polyline, data } + airports: [], + timer: null, + initialized:false, + units: 'imperial', // 'imperial' | 'metric' + filters: { + op: new Set(['civil','military','government','cargo','emergency','private','unknown']), + ac: new Set(['airplane','helicopter','drone','glider','balloon','unknown']), + }, +}; + +// ── Conversion d'unités ─────────────────────────────────────────────────────── +const UNITS = { + speed: ac => MAP_STATE.units === 'metric' + ? { val: Math.round((ac||0) * 1.852), unit: 'km/h' } + : { val: Math.round(ac||0), unit: 'kt' }, + alt: ft => MAP_STATE.units === 'metric' + ? { val: Math.round((ft||0) * 0.3048), unit: 'm' } + : { val: Math.round(ft||0), unit: 'ft' }, + dist: nm => MAP_STATE.units === 'metric' + ? { val: ((nm||0) * 1.852).toFixed(1), unit: 'km' } + : { val: Math.round(nm||0), unit: 'NM' }, +}; + +// Aéroports et aérodromes Île-de-France +const IDF_AIRPORTS = [ + // Grands aéroports + { name:'CDG — Charles de Gaulle', icao:'LFPG', lat:49.0097, lon:2.5479, type:'major' }, + { name:'Orly', icao:'LFPO', lat:48.7233, lon:2.3794, type:'major' }, + { name:'Le Bourget', icao:'LFPB', lat:48.9694, lon:2.4414, type:'major' }, + // Aérodromes + { name:'Pontoise - Cormeilles', icao:'LFPT', lat:49.0958, lon:2.0408, type:'aerodrome' }, + { name:'Toussus-le-Noble', icao:'LFPN', lat:48.7519, lon:2.1069, type:'aerodrome' }, + { name:'Lognes - Emerainville', icao:'LFPL', lat:48.8239, lon:2.6236, type:'aerodrome' }, + { name:'Etampes - Mondésir', icao:'LFOX', lat:48.3897, lon:2.0681, type:'aerodrome' }, + { name:'Persan - Beaumont', icao:'LFPP', lat:49.1722, lon:2.3319, type:'aerodrome' }, + { name:'Saint-Cyr-l\'École', icao:'LFPZ', lat:48.7072, lon:2.0747, type:'aerodrome' }, + { name:'Chelles - Le Pin', icao:'LFPH', lat:48.8833, lon:2.6333, type:'aerodrome' }, + { name:'Meaux - Esbly', icao:'LFPE', lat:48.9264, lon:2.8333, type:'aerodrome' }, +]; + +// Villes principales Île-de-France +const IDF_CITIES = [ + { name:'Paris', lat:48.8566, lon:2.3522 }, + { name:'Versailles', lat:48.8014, lon:2.1301 }, + { name:'Saint-Denis', lat:48.9362, lon:2.3574 }, + { name:'Créteil', lat:48.7904, lon:2.4556 }, + { name:'Nanterre', lat:48.8924, lon:2.2070 }, + { name:'Évry', lat:48.6241, lon:2.4271 }, + { name:'Marne-la-V.', lat:48.8500, lon:2.7000 }, + { name:'Pontoise', lat:49.0500, lon:2.1000 }, + { name:'Meaux', lat:48.9600, lon:2.8780 }, + { name:'Melun', lat:48.5400, lon:2.6600 }, +]; + +// ── Couleurs par altitude ───────────────────────────────────────────────────── +function altitudeColor(alt) { + if (!alt || alt <= 0) return '#6e7681'; + if (alt < 3000) return '#39d353'; + if (alt < 8000) return '#58a6ff'; + if (alt < 15000) return '#79c0ff'; + if (alt < 25000) return '#d29922'; + if (alt < 35000) return '#ffa657'; + return '#f85149'; +} + +// ── Couleurs de trajectoire par type d'opérateur ────────────────────────────── +const TRAJ_COLORS = { + civil:'#3fb950',military:'#f85149',cargo:'#d29922', + emergency:'#ff79c6',government:'#79c0ff',private:'#8b949e',unknown:'#6e7681', +}; + +// ── Classification précise depuis le code desc ICAO ───────────────────────── +// desc format : L2J (L=avion, 2=moteurs, J=jet) + wtc (L/M/H) +function classifyAircraft(desc, wtc, op_type) { + if (!desc) return 'airplane_generic'; + const cat = desc[0] || ''; // L H G B A T + const eng = desc[1] || ''; // 1 2 3 4 6 8 + const prop = desc[2] || ''; // P T J E + const wt = wtc || 'L'; + + if (cat === 'H') return 'helicopter'; + if (cat === 'G') return 'glider'; + if (cat === 'B') return 'balloon'; + + if (op_type === 'military') return 'military'; + if (op_type === 'emergency') return 'emergency'; + + if (cat === 'L' || cat === 'A') { + // Jets + if (prop === 'J' || prop === 'E') { + if (eng === '4') return wt === 'H' ? 'jet_quad_heavy' : 'jet_quad'; + if (eng === '2' || eng === '3') { + if (wt === 'H') return 'jet_twin_heavy'; // B777, A330, B787 + if (wt === 'M') { + if (op_type === 'cargo') return 'jet_twin_cargo'; + if (op_type === 'private' || op_type === 'government') return 'bizjet'; + return 'jet_twin_medium'; // A320, B737 + } + return 'bizjet'; // Citation, Learjet, Falcon + } + if (eng === '1') return 'bizjet'; // très rare — VFJ + } + // Turboprop + if (prop === 'T') { + if (eng === '1') return 'turboprop_single'; // PC12, TBM, Pilatus + return 'turboprop_twin'; // ATR42/72, C130, King Air + } + // Piston hélice + if (prop === 'P') { + if (eng === '1') return wt === 'L' ? 'piston_single' : 'piston_single_heavy'; + return 'piston_twin'; // PA34, Beech Baron + } + } + return 'airplane_generic'; +} + +// ── SVG par classe d'aéronef — silhouettes réalistes v3 ───────────────────── +// Les éléments moteurs/hélices/réacteurs proviennent des fichiers SVG dédiés +// (01_c172, 02_bonanza, 03_baron, 04_pc12, 05_atr72, 06_citation, 07_b737, +// 08_a320) convertis en viewBox 0 0 40 40 depuis leur viewBox 0 0 48 48 +// par facteur d'échelle 40/48 ≈ 0.833. +const AC_SVGS = { + + // 1. CESSNA / PA28 — monomoteur hélice léger + // Hélice : 01_c172_fill.svg — cercle moyeu + pales + fuselage + piston_single: (c,s)=>` + + + + + + + `, + + // 2. BONANZA / PA46 — monomoteur hélice lourd, empennage en V + // Hélice : 02_bonanza_fill.svg — cercle moyeu légèrement plus grand + piston_single_heavy: (c,s)=>` + + + + + + + `, + + // 3. PA34 / BEECH BARON — bimoteur piston, 2 nacelles + // Nacelles moteur : 03_baron_fill.svg — 2 ellipses (cx14,cy19) et (cx34,cy19) / 48 + // → scale 40/48 : cx≈11.7,cy≈15.8 et cx≈28.3,cy≈15.8 rx≈1.83 ry≈2.5 + piston_twin: (c,s)=>` + + + + + + `, + + // 4. PC12 / TBM / PILATUS — turboprop monomoteur, nez long + // Hélice : 04_pc12_fill.svg — cercle moyeu r=2.8/48 → r≈2.3 + turboprop_single: (c,s)=>` + + + + + + `, + + // 5. ATR72 / ATR42 / C130 — turboprop bimoteur, ailes très larges + // Hélices : 05_atr72_fill.svg — 2 cercles (cx15,cy15.8,r3.2) et (cx33,cy15.8,r3.2) / 48 + // → scale 40/48 : cx≈12.5,cy≈13.2 et cx≈27.5,cy≈13.2 r≈2.67 + // Contour (outline) : 05_atr72_outline.svg — stroke cercles r3.4 → r≈2.83 + turboprop_twin: (c,s)=>` + + + + + + + + `, + + // 6. LEARJET / FALCON / CITATION — bizjet, réacteurs arrière + // Réacteurs : 06_citation_fill.svg — 2 ellipses (cx20.4,cy30,rx1.4,ry3.2) et (cx27.6,cy30) / 48 + // → scale 40/48 : cx≈17,cy≈25 rx≈1.17 ry≈2.67 et cx≈23,cy≈25 + // Outline : 06_citation_outline.svg — stroke cx20.5,cx27.5 → cx17.1,cx22.9 + bizjet: (c,s)=>` + + + + + + + + `, + + // 7. B737 — bimoteur jet commercial standard, réacteurs sous ailes arrondis + // Réacteurs : 07_b737_fill.svg — 2 ellipses (cx15,cy22.5,rx2.1,ry2.9) et (cx33,cy22.5) / 48 + // → scale 40/48 : cx≈12.5,cy≈18.75 rx≈1.75 ry≈2.42 et cx≈27.5 + jet_twin_medium: (c,s)=>` + + + + + + `, + + // 8. A320 — bimoteur jet commercial, réacteurs légèrement plus larges + // Réacteurs : 08_a320_fill.svg — 2 ellipses (cx15,cy23,rx2.5,ry3.2) / 48 + // → scale 40/48 : cx≈12.5,cy≈19.2 rx≈2.08 ry≈2.67 et cx≈27.5 + // Outline : 08_a320_outline.svg (stroke) + // Note : jet_twin_medium_a320 utilisé comme alias de jet_twin_medium + // avec réacteurs légèrement plus larges pour différencier visuellement + jet_twin_medium_a320: (c,s)=>` + + + + + + + + `, + + // 9. B767F / A330F — cargo, bosse dorsale + jet_twin_cargo: (c,s)=>` + + + + + + + `, + + // 10. B777 / A330 / B787 — bimoteur lourd, très gros réacteurs + jet_twin_heavy: (c,s)=>` + + + + + + `, + + // 11. B747 / A340 — quadrimoteur, bosse avant + jet_quad: (c,s)=>` + + + + + + + + + `, + + // 12. A380 — double pont, 4 réacteurs, très large + jet_quad_heavy: (c,s)=>` + + + + + + + + + `, + + // 13. RAFALE / F16 / MIRAGE — delta, plans canards, dérives + military: (c,s)=>` + + + + + + + + + `, + + // 14. HELICOPTER — rotor principal + queue + patins + helicopter: (c,s)=>` + + + + + + + + + + `, + + // Secours — hélico avec croix rouge + emergency: (c,s)=>` + + + + + + + + + + + `, + + // Gouvernement — jet commercial avec étoile + government: (c,s)=>` + + + + + + + `, + + // Drone — carré + 4 bras + rotors + drone: (c,s)=>` + + + + + + + + + + `, + + // Planeur — ailes très longues, sans moteur + glider: (c,s)=>` + + + + `, + + // Ballon — ellipse + nacelle + balloon: (c,s)=>` + + + + `, + + // Générique + airplane_generic: (c,s)=>` + + + + `, +}; + +// Alias : A320 utilise la variante avec réacteurs légèrement plus larges +// (différenciation visuelle B737 vs A320) +AC_SVGS.jet_twin_medium = AC_SVGS.jet_twin_medium_a320; + +function getSVG(acClass, color, sz=30) { + const fn = AC_SVGS[acClass] || AC_SVGS.airplane_generic; + return fn(color, sz); +} + +function planeIcon(heading, ac_type, op_type, alt, desc, wtc) { + const color = altitudeColor(alt); + const acClass = classifyAircraft(desc, wtc, op_type); + const svg = getSVG(acClass, color, 30); + // Pas de rotation pour les éléments sans cap défini + const noRot = ['drone','balloon'].includes(acClass); + const rot = noRot ? 0 : (heading||0); + const b64 = btoa(unescape(encodeURIComponent(svg))); + return L.divIcon({ + className:'plane-icon', + html:`
    + +
    `, + iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-15], + }); +} + +function airportIcon(type) { + const color = type === 'major' ? '#58a6ff' : '#6e7681'; + const size = type === 'major' ? 16 : 11; + return L.divIcon({ + className: 'airport-label', + html: `
    `, + iconSize: [size, size], + iconAnchor: [size/2, size/2], + }); +} + +// ── Appliquer les filtres (show/hide markers + polylines) ──────────────────── +function applyFilters() { + let visible = 0; + const counts = {civil:0,military:0,cargo:0,emergency:0,government:0,private:0,unknown:0}; + + for (const [icao, layer] of Object.entries(MAP_STATE.layers)) { + const d = layer.data; + if (!d) continue; + const opType = d.operator_type || 'unknown'; + const acType = (d.aircraft_type || 'unknown').toLowerCase(); + const acKey = acType in {'airplane':1,'helicopter':1,'drone':1,'glider':1,'balloon':1} + ? acType : 'unknown'; + const show = MAP_STATE.filters.op.has(opType) && MAP_STATE.filters.ac.has(acKey); + if (show) { + layer.marker?.getElement() && (layer.marker.getElement().style.display = ''); + layer.polyline?.setStyle({ opacity: 0.7 }); + visible++; + if (opType in counts) counts[opType]++; + } else { + layer.marker?.getElement() && (layer.marker.getElement().style.display = 'none'); + layer.polyline?.setStyle({ opacity: 0 }); + } + } + + // Mettre à jour les compteurs du panneau + const total = Object.keys(MAP_STATE.layers).length; + document.getElementById('traj-count').textContent = `${total} avion(s)`; + document.getElementById('traj-visible').textContent = `${visible} visibles`; + const setEl = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v||'0'; }; + setEl('ps-total', total); + setEl('ps-visible', visible); + setEl('ps-civil', counts.civil); + setEl('ps-military', counts.military); + setEl('ps-cargo', counts.cargo); + setEl('ps-emergency', counts.emergency); + setEl('ps-government', counts.government); + setEl('ps-private', counts.private); +} + +// ── Mettre à jour les labels altitude selon les unités ──────────────────────── +function updateUnitLabels() { + const isMetric = MAP_STATE.units === 'metric'; + document.querySelectorAll('.alt-label').forEach(el => { + el.textContent = isMetric ? el.dataset.m : el.dataset.ft; + }); + // Rafraîchir tous les popups ouverts + for (const layer of Object.values(MAP_STATE.layers)) { + if (layer.data && layer.marker?.isPopupOpen()) { + updatePopup(layer.marker, layer.data); + } + } +} + +// ── Init des contrôles du panneau ───────────────────────────────────────────── +function initPanelControls() { + // Filtres opérateur + document.querySelectorAll('.filter-op').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) MAP_STATE.filters.op.add(cb.value); + else MAP_STATE.filters.op.delete(cb.value); + applyFilters(); + }); + }); + + // Filtres aéronef + document.querySelectorAll('.filter-ac').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) MAP_STATE.filters.ac.add(cb.value); + else MAP_STATE.filters.ac.delete(cb.value); + applyFilters(); + }); + }); + + // Unités + document.getElementById('unit-imperial')?.addEventListener('click', () => { + MAP_STATE.units = 'imperial'; + document.getElementById('unit-imperial')?.classList.add('active'); + document.getElementById('unit-metric')?.classList.remove('active'); + updateUnitLabels(); + }); + document.getElementById('unit-metric')?.addEventListener('click', () => { + MAP_STATE.units = 'metric'; + document.getElementById('unit-metric')?.classList.add('active'); + document.getElementById('unit-imperial')?.classList.remove('active'); + updateUnitLabels(); + }); +} + +function initMap() { + if (MAP_STATE.initialized) return; + MAP_STATE.initialized = true; + + // Centre : Issy-les-Moulineaux, zoom pour 80×80km + const center = [48.8236, 2.2770]; + MAP_STATE.map = L.map('map-idf', { + center, + zoom: 10, + zoomControl: true, // boutons +/- + scrollWheelZoom: true, // molette + attributionControl: true, + }); + addFitControl(MAP_STATE.map, 'Revoir tous les appareils affichés'); + + // Tuiles CartoDB Dark Matter + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OSM © CARTO', + subdomains: 'abcd', + maxZoom: 19, + }).addTo(MAP_STATE.map); + + // Cercle de zone (80km de diamètre = 40km de rayon) + L.circle(center, { + radius: 40000, + color: '#58a6ff', + weight: 1, + opacity: 0.3, + fillOpacity: 0.03, + dashArray: '6 4', + }).addTo(MAP_STATE.map); + + // Villes + IDF_CITIES.forEach(c => { + L.marker([c.lat, c.lon], { + icon: L.divIcon({ + className: 'airport-label', + html: `
    + ${c.name} +
    `, + iconAnchor: [0, 0], + }), + interactive: false, + }).addTo(MAP_STATE.map); + }); + + // Aéroports et aérodromes — nom complet sur la carte, ICAO dans le popup + IDF_AIRPORTS.forEach(ap => { + const isMajor = ap.type === 'major'; + const dotColor = isMajor ? '#79c0ff' : '#8b949e'; + const dotSize = isMajor ? 14 : 9; + const fontSize = isMajor ? '11' : '10'; + const fontWeight = isMajor ? '700' : '500'; + + // Point coloré cliquable + const marker = L.marker([ap.lat, ap.lon], { + icon: L.divIcon({ + className: 'airport-label', + html: `
    `, + iconSize: [dotSize, dotSize], + iconAnchor: [dotSize/2, dotSize/2], + }), + }); + + // Label : nom court pour les majeurs, abréviation pour les aérodromes + const shortName = isMajor + ? ap.name.replace(' — Charles de Gaulle','').replace('Charles de Gaulle','CDG') + : ap.name.split(' - ')[0].split(' — ')[0]; + + const label = L.marker([ap.lat, ap.lon], { + icon: L.divIcon({ + className: 'airport-label', + html: `
    + ${shortName} +
    `, + iconAnchor: [0, parseInt(fontSize)/2], + }), + interactive: false, + }); + + // Popup avec toutes les infos au clic + marker.bindPopup(` + +
    ${ap.name}
    + + + + `); + marker.addTo(MAP_STATE.map); + label.addTo(MAP_STATE.map); + }); + + initPanelControls(); + // Charger les trajectoires immédiatement + loadTrajectories(); +} + +async function loadTrajectories() { + + const minutes = parseInt(document.getElementById('traj-minutes')?.value || 30); + try { + const d = await fetch(`/api.php?action=trajectories&minutes=${minutes}`).then(r=>r.json()); + const aircraft = d.aircraft || []; + + document.getElementById('traj-count').textContent = `${aircraft.length} avion(s)`; + document.getElementById('traj-update').textContent = 'Mis à jour : ' + new Date().toLocaleTimeString('fr-FR'); + + // Ensemble des ICAO reçus + const seen = new Set(aircraft.map(a => a.icao)); + + // Supprimer les avions disparus + for (const icao of Object.keys(MAP_STATE.layers)) { + if (!seen.has(icao)) { + MAP_STATE.layers[icao].marker?.remove(); + MAP_STATE.layers[icao].polyline?.remove(); + delete MAP_STATE.layers[icao]; + } + } + + // Mettre à jour ou créer chaque avion + aircraft.forEach(ac => { + // desc et wtc viennent directement de la base PostgreSQL + const color = TRAJ_COLORS[ac.operator_type] || TRAJ_COLORS.unknown; + const track = ac.track || []; + const latlngs = track.map(p => [p.lat, p.lon]); + // Altitude : prendre la dernière valeur connue > 0 dans le track + const knownAlts = track.map(p=>p.alt).filter(a=>a&&a>0); + const currentAlt = knownAlts.length ? knownAlts[knownAlts.length-1] : (ac.alt||0); + // Trajectoire colorée par altitude (dégradé sur les points) + const trajColor = altitudeColor(currentAlt); + + if (!MAP_STATE.layers[ac.icao]) { + // Nouveau + const polyline = L.polyline(latlngs, { + color:trajColor, weight: 2, opacity: 0.7, dashArray: null, + }).addTo(MAP_STATE.map); + const marker = L.marker([ac.lat, ac.lon], { + icon: planeIcon(ac.hdg||0, ac.aircraft_type, ac.operator_type, currentAlt, ac.desc, ac.wtc), + zIndexOffset: 1000, + }).addTo(MAP_STATE.map); + marker.bindPopup(''); + marker.on('click', () => updatePopup(marker, ac)); + MAP_STATE.layers[ac.icao] = { marker, polyline }; + } else { + // Mise à jour + MAP_STATE.layers[ac.icao].polyline.setLatLngs(latlngs); + MAP_STATE.layers[ac.icao].polyline.setStyle({ color:trajColor }); + MAP_STATE.layers[ac.icao].marker.setLatLng([ac.lat, ac.lon]); + MAP_STATE.layers[ac.icao].marker.setIcon(planeIcon(ac.hdg||0, ac.aircraft_type, ac.operator_type, currentAlt, ac.desc, ac.wtc)); + } + + // Stocker les données pour le popup et les filtres + MAP_STATE.layers[ac.icao].data = ac; + }); + + applyFilters(); + } catch(e) { + console.error('loadTrajectories:', e); + } +} + +function updatePopup(marker, ac) { + const last = ac.track?.length ? ac.track[ac.track.length - 1] : {}; + const first = ac.track?.length ? ac.track[0] : {}; + const dur = (ac.track?.length > 1) + ? Math.round((ac.track.length * 5) / 60) + ' min' + : '—'; + const altColor = altitudeColor(last.alt); + const opLabels = {civil:'Civil',military:'Militaire',cargo:'Cargo', + emergency:'Secours',government:'Gouvernement',private:'Privé',unknown:'Inconnu'}; + const acLabels = {airplane:'Avion',helicopter:'Hélicoptère',drone:'Drone', + glider:'Planeur',balloon:'Ballon',unknown:'Inconnu'}; + + marker.setPopupContent(` +
    +
    +
    + +
    ${ac.callsign||'Indicatif inconnu'}
    +
    +
    +
    ${last.alt?(()=>{const u=UNITS.alt(last.alt);return u.val.toLocaleString('fr-FR')+' '+u.unit;})():'— ft'}
    +
    altitude
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Type aéronef${acLabels[ac.aircraft_type]||ac.aircraft_type||'—'}${ac.desc?' ('+ac.desc+')':''}
    Opérateur${opLabels[ac.operator_type]||ac.operator_type||'—'}
    Vitesse sol${last.spd?UNITS.speed(last.spd).val+' '+UNITS.speed(last.spd).unit:'—'}
    Cap${last.hdg?Math.round(last.hdg)+'°':'—'}
    Position${last.lat?last.lat.toFixed(4)+'° N':'—'}
    ${last.lon?last.lon.toFixed(4)+'° E':'—'}
    Points tracés${ac.track?.length||0} (~${dur})
    Vu depuis${first.ts||'—'}
    +
    +
    +
    + Dernière position : ${last.ts||'—'} +
    + +
    +
    + `); + marker.openPopup(); +} + +// ── Init carte au changement d'onglet ───────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('#mainTabs .nav-link').forEach(tab => { + tab.addEventListener('shown.bs.tab', e => { + if (e.target.getAttribute('href') === '#tab-carte-idf') { + if (!MAP_STATE.initialized) { + initMap(); + } else { + MAP_STATE.map.invalidateSize(); + loadTrajectories(); + } + // Timer 5s pour la carte + if (!MAP_STATE.timer) { + MAP_STATE.timer = setInterval(loadTrajectories, 5000); + } + } else { + // Stopper le timer si on quitte l'onglet carte + if (MAP_STATE.timer) { + clearInterval(MAP_STATE.timer); + MAP_STATE.timer = null; + } + } + }); + }); + + // Contrôles carte + document.getElementById('traj-minutes')?.addEventListener('change', loadTrajectories); + document.getElementById('traj-refresh')?.addEventListener('click', loadTrajectories); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CARTE HISTORIQUE — Leaflet + trajectoires PostgreSQL agrégées +// ═══════════════════════════════════════════════════════════════════════════ + +const HIST_STATE = { + map: null, + layers: {}, + initialized:false, + loading: false, + units: 'imperial', + filters: { + op: new Set(['civil','military','government','cargo','emergency','private','unknown']), + ac: new Set(['airplane','helicopter','drone','glider','balloon','unknown']), + }, +}; + +const STRATEGY_LABELS = { + raw_5s: 'Brut 5s', + agg_1min: 'Agrégé 1min', + agg_5min: 'Agrégé 5min', + agg_15min: 'Agrégé 15min', + history_sessions:'Sessions history', +}; + +// ── Initialisation de la carte historique ──────────────────────────────────── +function initMapHist() { + if (HIST_STATE.initialized) return; + HIST_STATE.initialized = true; + + const center = [48.8236, 2.2770]; + HIST_STATE.map = L.map('map-hist', + { center, zoom: 10, zoomControl: true, scrollWheelZoom: true }); + addFitControl(HIST_STATE.map, 'Revoir toutes les trajectoires affichées'); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OSM © CARTO', + subdomains: 'abcd', maxZoom: 19, + }).addTo(HIST_STATE.map); + + // Cercle zone 40km + L.circle(center, { + radius:40000, color:'#58a6ff', weight:1, opacity:0.3, + fillOpacity:0.03, dashArray:'6 4', + }).addTo(HIST_STATE.map); + + // Villes + IDF_CITIES.forEach(c => { + L.marker([c.lat, c.lon], { + icon: L.divIcon({ + className:'airport-label', + html:`
    ${c.name}
    `, + iconAnchor:[0,0], + }), interactive:false, + }).addTo(HIST_STATE.map); + }); + + // Aéroports + IDF_AIRPORTS.forEach(ap => { + const isMajor = ap.type==='major'; + const dotColor = isMajor?'#79c0ff':'#8b949e'; + const dotSize = isMajor?14:9; + const m = L.marker([ap.lat,ap.lon],{icon:L.divIcon({ + className:'airport-label', + html:`
    `, + iconSize:[dotSize,dotSize],iconAnchor:[dotSize/2,dotSize/2], + })}); + const shortName = isMajor + ? ap.name.replace(' — Charles de Gaulle','') + : ap.name.split(' - ')[0].split(' — ')[0]; + L.marker([ap.lat,ap.lon],{icon:L.divIcon({ + className:'airport-label', + html:`
    ${shortName}
    `, + iconAnchor:[0,6], + }),interactive:false}).addTo(HIST_STATE.map); + m.bindPopup(`
    ${ap.name}
    `); + m.addTo(HIST_STATE.map); + }); + + initHistControls(); + + // Initialiser la date à maintenant - 30 minutes + const now = new Date(); + now.setMinutes(now.getMinutes() - 30); + document.getElementById('hist-from').value = toLocalDatetimeInput(now); +} + +function toLocalDatetimeInput(date) { + const d = new Date(date); + d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); + return d.toISOString().slice(0,16); +} + +// ── Chargement des trajectoires historiques ─────────────────────────────────── +async function loadHistTrajectories() { + if (HIST_STATE.loading) return; + HIST_STATE.loading = true; + + const fromVal = document.getElementById('hist-from')?.value; + const windowMin = parseInt(document.getElementById('hist-window')?.value || 30); + + if (!fromVal) { + HIST_STATE.loading = false; + return; + } + + // Afficher la barre de chargement + const loadingEl = document.getElementById('hist-loading'); + const loadingMsg = document.getElementById('hist-loading-msg'); + loadingEl?.classList.remove('d-none'); + if (loadingMsg) loadingMsg.textContent = windowMin > 10080 + ? 'Chargement depuis aircraft_history…' + : `Chargement et agrégation (${windowMin < 60 ? windowMin+'min' : Math.round(windowMin/60)+'h'})…`; + + // Vider la carte + for (const layer of Object.values(HIST_STATE.layers)) { + layer.marker?.remove(); + layer.polyline?.remove(); + } + HIST_STATE.layers = {}; + + try { + const params = new URLSearchParams({ + action: 'trajectories_history', + from: fromVal.replace('T',' ') + ':00', + window: windowMin, + }); + const d = await fetch(`/api.php?${params}`).then(r=>r.json()); + const aircraft = d.aircraft || []; + + // Badge stratégie + const stratLabel = STRATEGY_LABELS[d.strategy] || d.strategy || '—'; + document.getElementById('hist-strategy').textContent = stratLabel; + document.getElementById('hps-strategy').textContent = stratLabel; + + // Dates + const fromFmt = d.from ? new Date(d.from).toLocaleString('fr-FR',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'}) : '—'; + const toFmt = d.to ? new Date(d.to).toLocaleString('fr-FR',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'}) : '—'; + document.getElementById('hps-from').textContent = fromFmt; + document.getElementById('hps-to').textContent = toFmt; + + // Construire les couches + aircraft.forEach(ac => { + const track = ac.track || []; + if (track.length < 1) return; + + const latlngs = track.map(p=>[p.lat,p.lon]); + const knownAlts = track.map(p=>p.alt).filter(a=>a&&a>0); + const curAlt = knownAlts.length ? knownAlts[knownAlts.length-1] : 0; + const trajColor = altitudeColor(curAlt); + const opColor = TRAJ_COLORS[ac.operator_type] || TRAJ_COLORS.unknown; + + const polyline = L.polyline(latlngs, { + color: trajColor, weight: 2, opacity: 0.7, + }).addTo(HIST_STATE.map); + + // Marqueur sur la dernière position + const lastPos = track[track.length-1]; + const marker = L.marker([lastPos.lat, lastPos.lon], { + icon: planeIcon(lastPos.hdg||0, ac.aircraft_type, ac.operator_type, curAlt, ac.desc, ac.wtc), + zIndexOffset: 1000, + }).addTo(HIST_STATE.map); + + // Popup + marker.bindPopup(''); + marker.on('click', () => { + const acData = { ...ac, track }; + updateHistPopup(marker, acData, d.strategy); + }); + + HIST_STATE.layers[ac.icao + '_' + Math.random()] = { marker, polyline, data: ac }; + }); + + applyHistFilters(); + + } catch(e) { + console.error('loadHistTrajectories:', e); + } finally { + HIST_STATE.loading = false; + loadingEl?.classList.add('d-none'); + } +} + +// ── Popup enrichi pour la carte historique ──────────────────────────────────── +function updateHistPopup(marker, ac, strategy) { + const last = ac.track?.length ? ac.track[ac.track.length-1] : {}; + const first = ac.track?.length ? ac.track[0] : {}; + const altU = HIST_STATE.units === 'metric' + ? { val: Math.round((last.alt||0)*0.3048), unit:'m' } + : { val: last.alt||0, unit:'ft' }; + const spdU = HIST_STATE.units === 'metric' + ? { val: Math.round((last.spd||0)*1.852), unit:'km/h'} + : { val: last.spd||0, unit:'kt' }; + const altColor = altitudeColor(last.alt); + const acLabels = {airplane:'Avion',helicopter:'Hélicoptère',drone:'Drone', + glider:'Planeur',balloon:'Ballon',unknown:'Inconnu'}; + const opLabels = {civil:'Civil',military:'Militaire',cargo:'Cargo', + emergency:'Secours',government:'Gouvernement',private:'Privé',unknown:'Inconnu'}; + + marker.setPopupContent(` +
    +
    +
    + +
    ${ac.callsign||'Indicatif inconnu'}
    +
    +
    +
    + ${altU.val ? altU.val.toLocaleString('fr-FR')+' '+altU.unit : '—'} +
    +
    altitude moy.
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Type aéronef + ${acLabels[ac.aircraft_type]||ac.aircraft_type||'—'}${ac.desc?' ('+ac.desc+')':''} +
    Opérateur + ${opLabels[ac.operator_type]||ac.operator_type||'—'} +
    Vitesse moy. + ${spdU.val ? spdU.val+' '+spdU.unit : '—'} +
    Premier point${first.ts||'—'}
    Dernier point${last.ts||'—'}
    Points tracés${ac.track?.length||0}
    Agrégation + ${STRATEGY_LABELS[strategy]||strategy||'—'} +
    + +
    + `); + marker.openPopup(); +} + +// ── Filtres carte historique ────────────────────────────────────────────────── +function applyHistFilters() { + let visible = 0; + const counts = {civil:0,military:0,cargo:0,emergency:0,government:0,private:0,unknown:0}; + + for (const layer of Object.values(HIST_STATE.layers)) { + const d = layer.data; + if (!d) continue; + const opType = d.operator_type || 'unknown'; + const acType = (d.aircraft_type||'unknown').toLowerCase(); + const acKey = ['airplane','helicopter','drone','glider','balloon'].includes(acType) + ? acType : 'unknown'; + const show = HIST_STATE.filters.op.has(opType) && HIST_STATE.filters.ac.has(acKey); + if (show) { + layer.marker?.getElement() && (layer.marker.getElement().style.display = ''); + layer.polyline?.setStyle({ opacity: 0.7 }); + visible++; + if (opType in counts) counts[opType]++; + } else { + layer.marker?.getElement() && (layer.marker.getElement().style.display = 'none'); + layer.polyline?.setStyle({ opacity: 0 }); + } + } + + const total = Object.keys(HIST_STATE.layers).length; + const setEl = (id,v) => { const el=document.getElementById(id); if(el) el.textContent=v||'0'; }; + setEl('hist-count', total); + setEl('hist-visible', visible); + setEl('hps-total', total); + setEl('hps-visible', visible); + setEl('hps-civil', counts.civil); + setEl('hps-military', counts.military); + setEl('hps-cargo', counts.cargo); + setEl('hps-emergency', counts.emergency); + setEl('hps-government', counts.government); + setEl('hps-private', counts.private); +} + +// ── Contrôles panneau historique ────────────────────────────────────────────── +function initHistControls() { + document.getElementById('hist-go')?.addEventListener('click', loadHistTrajectories); + + document.getElementById('hist-now')?.addEventListener('click', () => { + const now = new Date(); + const win = parseInt(document.getElementById('hist-window')?.value||30); + now.setMinutes(now.getMinutes() - win); + document.getElementById('hist-from').value = toLocalDatetimeInput(now); + loadHistTrajectories(); + }); + + document.querySelectorAll('.hist-filter-op').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) HIST_STATE.filters.op.add(cb.value); + else HIST_STATE.filters.op.delete(cb.value); + applyHistFilters(); + }); + }); + + document.querySelectorAll('.hist-filter-ac').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) HIST_STATE.filters.ac.add(cb.value); + else HIST_STATE.filters.ac.delete(cb.value); + applyHistFilters(); + }); + }); + + document.getElementById('hist-unit-imperial')?.addEventListener('click', () => { + HIST_STATE.units = 'imperial'; + document.getElementById('hist-unit-imperial')?.classList.add('active'); + document.getElementById('hist-unit-metric')?.classList.remove('active'); + }); + document.getElementById('hist-unit-metric')?.addEventListener('click', () => { + HIST_STATE.units = 'metric'; + document.getElementById('hist-unit-metric')?.classList.add('active'); + document.getElementById('hist-unit-imperial')?.classList.remove('active'); + }); +} + +// ── Init au changement d'onglet ─────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('#mainTabs .nav-link').forEach(tab => { + tab.addEventListener('shown.bs.tab', e => { + if (e.target.getAttribute('href') === '#tab-carte-historique') { + if (!HIST_STATE.initialized) { + initMapHist(); + } else { + HIST_STATE.map.invalidateSize(true); + } + } + }); + }); +}); + +// ── Plein écran onglet État du stack ───────────────────────────────────────── +function toggleStatusFullscreen() { + const tab = document.getElementById('tab-status'); + const btn = document.getElementById('status-fullscreen-btn'); + if (!tab || !btn) return; + + const isFs = tab.classList.contains('status-fullscreen'); + if (isFs) { + tab.classList.remove('status-fullscreen'); + btn.innerHTML = ' Plein écran'; + document.body.style.overflow = ''; + } else { + tab.classList.add('status-fullscreen'); + btn.innerHTML = ' Réduire'; + document.body.style.overflow = 'hidden'; + } +} + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + const tab = document.getElementById('tab-status'); + if (tab && tab.classList.contains('status-fullscreen')) toggleStatusFullscreen(); + } +}); + +// ── Init iframe status au changement d'onglet ──────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + const statusTab = document.querySelector('a[href="#tab-status"]'); + if (statusTab) { + statusTab.addEventListener('shown.bs.tab', () => { + // Forcer le recalcul de la taille de l'iframe + const wrapper = document.querySelector('.status-iframe-wrapper'); + const frame = document.getElementById('status-frame'); + if (wrapper && frame) { + // Laisser le CSS s'appliquer puis forcer un repaint + setTimeout(() => { + frame.style.width = '100%'; + frame.style.height = '100%'; + // Recharger si le status.html n'est pas encore chargé + if (!frame.src || frame.src === 'about:blank') { + frame.src = '/status.html'; + } + }, 50); + } + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DÉPARTS HORS AÉRODROMES — liste + zones +// +// Détection : pour chaque appareil, la trace est découpée en segments séparés +// par un trou de plus de N minutes ; le point le plus BAS de chaque segment est +// retenu, filtré sur un taux de montée minimal, puis écarté s'il tombe dans le +// rayon d'un aérodrome connu. +// +// Limite : l'horizon radio masque un décollage à 40 km jusqu'à ~1500 ft. Le +// point affiché est le plus bas CAPTÉ, pas le point de décollage réel. +// ═══════════════════════════════════════════════════════════════════════════════ + +const DEP_STATE = { airports: [], rows: [] }; +const ZONE_STATE = { map: null, initialized: false, layer: null, aeroLayer: null, + trackLayer: null, trackIcao: null }; + +// Valeur par défaut des champs date : début de la période courante +function depDefaultFrom(minutesBack) { + const d = new Date(Date.now() - minutesBack * 60000); + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}` + + `T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function depLocalToParam(v) { + return v ? v.replace('T', ' ') + ':00' : ''; +} + +// ── Chargement de la liste des aérodromes (mise en cache) ──────────────────── +async function loadAirports() { + if (DEP_STATE.airports.length) return DEP_STATE.airports; + try { + const r = await fetch('api.php?action=airports'); + const d = await r.json(); + DEP_STATE.airports = d.airports || []; + } catch (e) { + console.error('airports:', e); + DEP_STATE.airports = []; + } + return DEP_STATE.airports; +} + +// ── Sélection des sources (dongles) ────────────────────────────────────────── +// Le composant sert les deux onglets (liste et zones), avec un état SÉPARÉ pour +// chacun : on veut pouvoir comparer une zone tous récepteurs confondus avec la +// même zone vue par un seul, sans que le choix fait ici modifie l'autre onglet. +// +// RX[x].selected vaut null tant que l'utilisateur n'a rien touché : cela +// signifie « toutes les sources » et se traduit par l'absence du paramètre +// receivers dans l'appel API. Une fois qu'il coche, c'est un tableau de clés où +// la chaîne 'null' représente les positions sans receiver_id. +const RX = { + dep: { list: [], unused: [], selected: null, loadedFor: null, + from: 'dep-from', window: 'dep-window', + menu: 'dep-src-menu', label: 'dep-src-label', btn: 'dep-src-btn' }, + zon: { list: [], unused: [], selected: null, loadedFor: null, + from: 'zon-from', window: 'zon-window', + menu: 'zon-src-menu', label: 'zon-src-label', btn: 'zon-src-btn' }, + wat: { list: [], unused: [], selected: null, loadedFor: null, + from: 'wat-from', window: 'wat-window', + menu: 'wat-src-menu', label: 'wat-src-label', btn: 'wat-src-btn' }, +}; + +function rxKey(rx) { + return rx.id === null || rx.id === undefined ? 'null' : String(rx.id); +} + +// La liste dépend de la fenêtre temporelle : un dongle actif l'an dernier n'a +// pas à figurer dans une recherche sur 24 h. On recharge donc quand la période +// change, mais pas à chaque ouverture de l'onglet. +async function loadReceivers(which, force) { + const C = RX[which]; + if (!C) return []; + const fromEl = document.getElementById(C.from); + const winEl = document.getElementById(C.window); + if (!fromEl || !winEl) return []; + + const from = depLocalToParam(fromEl.value); + const win = winEl.value; + const key = from + '|' + win; + if (!force && C.loadedFor === key) return C.list; + + const menu = document.getElementById(C.menu); + if (menu) menu.innerHTML = '
    ' + + ' Chargement…
    '; + + try { + const r = await fetch('api.php?' + new URLSearchParams({ + action: 'receivers', from, window: win, + })); + const d = await r.json(); + if (d.error) throw new Error(d.error); + C.list = d.receivers || []; + C.unused = d.declared_unused || []; + C.loadedFor = key; + } catch (e) { + console.error('receivers:', e); + C.list = []; C.unused = []; + } + + // Une sélection antérieure peut désigner des sources absentes de la nouvelle + // fenêtre : on la purge plutôt que de renvoyer zéro résultat sans explication. + if (C.selected) { + const avail = new Set(C.list.map(rxKey)); + C.selected = C.selected.filter(k => avail.has(k)); + if (!C.selected.length) C.selected = null; + } + + renderSourceMenu(which); + return C.list; +} + +function renderSourceMenu(which) { + const C = RX[which]; + const menu = document.getElementById(C.menu); + if (!menu) return; + + if (!C.list.length) { + menu.innerHTML = '
    ' + + 'Aucune source n\'a de données sur cette période.
    '; + updateSourceLabel(which); + return; + } + + const sel = C.selected; // null = toutes + const rows = C.list.map(rx => { + const k = rxKey(rx); + const checked = (sel === null || sel.includes(k)) ? 'checked' : ''; + const pts = Number(rx.pts).toLocaleString('fr-FR'); + const loc = rx.location_desc ? ` · ${esc(rx.location_desc)}` : ''; + const geo = (rx.lat == null || rx.lon == null) + ? ' ' + : ''; + const name = rx.id == null + ? 'Source non identifiée ' + : esc(rx.name); + return ``; + }).join(''); + + const foot = C.unused.length + ? ` +
    + ${C.unused.length} récepteur(s) déclaré(s) sans aucune donnée sur cette + période — non proposé(s) au filtrage.
    ` + : ''; + + menu.innerHTML = ` +
    + + +
    + + ${rows}${foot}`; + + // Le clic ne doit pas refermer le menu : on coche souvent plusieurs cases. + menu.querySelectorAll('.rx-cb').forEach(cb => { + cb.addEventListener('change', () => onSourceToggle(which)); + cb.closest('label').addEventListener('click', e => e.stopPropagation()); + }); + menu.querySelector('.rx-all')?.addEventListener('click', e => { + e.stopPropagation(); + menu.querySelectorAll('.rx-cb').forEach(cb => cb.checked = true); + onSourceToggle(which); + }); + menu.querySelector('.rx-none')?.addEventListener('click', e => { + e.stopPropagation(); + menu.querySelectorAll('.rx-cb').forEach(cb => cb.checked = false); + onSourceToggle(which); + }); + + updateSourceLabel(which); +} + +function onSourceToggle(which) { + const C = RX[which]; + const menu = document.getElementById(C.menu); + const all = [...menu.querySelectorAll('.rx-cb')]; + const on = all.filter(cb => cb.checked).map(cb => cb.value); + // Tout coché ou rien coché = aucune restriction. Un filtre vide qui renverrait + // zéro ligne serait un piège : l'utilisateur croirait à une absence de trafic. + C.selected = (on.length === 0 || on.length === all.length) ? null : on; + updateSourceLabel(which); +} + +function updateSourceLabel(which) { + const C = RX[which]; + const lbl = document.getElementById(C.label); + const btn = document.getElementById(C.btn); + if (!lbl) return; + const total = C.list.length; + if (C.selected === null) { + lbl.textContent = total ? `Toutes (${total})` : 'Toutes'; + btn?.classList.remove('btn-info'); + btn?.classList.add('btn-outline-secondary'); + } else { + const n = C.selected.length; + if (n === 1) { + const rx = C.list.find(r => rxKey(r) === C.selected[0]); + lbl.textContent = rx ? (rx.id == null ? 'Non identifiée' : rx.name) : '1 source'; + } else { + lbl.textContent = `${n} / ${total} sources`; + } + btn?.classList.add('btn-info'); + btn?.classList.remove('btn-outline-secondary'); + } +} + +// Ajoute le paramètre receivers à un URLSearchParams, si et seulement si la +// sélection est partielle. +function rxApply(which, params) { + const sel = RX[which].selected; + if (sel && sel.length) params.set('receivers', sel.join(',')); + return params; +} + +// Cellule « Source » des tableaux : nom du récepteur ayant capté le point +// retenu, plus un compteur des autres sources ayant vu le même segment. +function rxShortName(which, key) { + if (key === 'null' || key == null) return 'Non identifiée'; + const rx = RX[which].list.find(r => rxKey(r) === String(key)); + return rx ? (rx.name || ('#' + key)) : ('#' + key); +} + +function rxCell(which, a) { + const all = (a.sources || '').split(',').filter(Boolean); + const main = a.receiver_id == null ? 'null' : String(a.receiver_id); + const oth = all.filter(k => k !== main); + const name = a.receiver_name || rxShortName(which, main); + if (RX[which].list.length <= 1 && !oth.length) { + return ''; + } + return `${esc(name)}` + + (oth.length + ? ` +${oth.length}` + : ''); +} + +// ── Onglet 1 : liste des départs ───────────────────────────────────────────── +async function loadDepartures() { + const tbody = document.getElementById('dep-tbody'); + const badge = document.getElementById('dep-count'); + const trunc = document.getElementById('dep-trunc'); + + tbody.innerHTML = '' + + ' Recherche en cours…'; + trunc.classList.add('d-none'); + + const params = new URLSearchParams({ + action: 'departures_offfield', + from: depLocalToParam(document.getElementById('dep-from').value), + window: document.getElementById('dep-window').value, + alt_max: document.getElementById('dep-altmax').value, + vrate: document.getElementById('dep-vrate').value, + margin: document.getElementById('dep-margin').value, + max: 500, + }); + + // Sélection partielle uniquement : « toutes les sources » n'envoie rien, + // ce qui évite d'imposer une liste d'ids figée à une base qui en gagnera. + rxApply('dep', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const rows = d.departures || []; + badge.textContent = `${d.count} départ(s)`; + if (d.truncated) trunc.classList.remove('d-none'); + + if (!rows.length) { + tbody.innerHTML = '' + + 'Aucun départ détecté hors des aérodromes connus sur cette période.
    ' + + 'Essaie d\'élargir la période, d\'augmenter l\'altitude max ' + + 'ou de baisser le seuil de montée.'; + return; + } + + // Occurrences par appareil sur l'ensemble des résultats + const occ = {}; + rows.forEach(a => { occ[a.icao] = (occ[a.icao] || 0) + 1; }); + DEP_STATE.rows = rows; + + tbody.innerHTML = rows.map((a, idx) => { + const alt = a.altitude != null ? Number(a.altitude).toLocaleString('fr-FR') + ' ft' : '—'; + const vr = a.vrate != null ? (a.vrate > 0 ? '+' : '') + a.vrate + ' ft/min' : '—'; + const spd = a.speed != null ? a.speed + ' kt' : '—'; + const pos = `${Number(a.lat).toFixed(4)}, ${Number(a.lon).toFixed(4)}`; + // Plus le point est loin de la station, moins il est fiable + const dstn = Number(a.dist_station_km); + const cls = dstn > 60 ? 'text-danger' : (dstn > 35 ? 'text-warning' : 'text-success'); + + const n = occ[a.icao]; + const occCls = n >= 5 ? 'bg-danger' : (n >= 3 ? 'bg-warning text-dark' + : (n >= 2 ? 'bg-info text-dark' : 'bg-dark border')); + + const typ = a.aircraft_desc || a.aircraft_type || ''; + const typTip = decodeAcType(typ); + const typCell = typ + ? (typTip ? `${typ}` : typ) + : ''; + + const srcCell = rxCell('dep', a); + + const wtcTip = decodeWtc(a.aircraft_wtc); + const wtcCell = a.aircraft_wtc + ? (wtcTip ? `${a.aircraft_wtc}` + : a.aircraft_wtc) + : ''; + + return ` + ${a.ts_local || '—'} + ${n} + ${a.icao} + ${a.callsign || ''} + ${typCell} + ${wtcCell} + ${countryFlag(a.country)}${a.country || '—'} + ${a.operator_name || ''} + ${alt} + ${vr} + ${phaseCell(a)} + ${spd} + ${pos} + ${a.aero_proche || '—'} + ${a.dist_aero_km ?? '?'} km + ${a.dist_station_km ?? '?'} km + ${srcCell} + ${watchBtn(a.icao)} + + + + + `; + }).join(''); + + zdInitTooltips(tbody); + + } catch (e) { + console.error('departures:', e); + tbody.innerHTML = ` + Erreur : ${e.message}`; + badge.textContent = '0 départ(s)'; + } +} + +// ── Onglet 2 : carte des zones ─────────────────────────────────────────────── +function initMapZones() { + if (ZONE_STATE.initialized) return; + ZONE_STATE.initialized = true; + + const center = [48.8236, 2.2770]; + ZONE_STATE.map = L.map('map-zones', + { center, zoom: 9, zoomControl: true, scrollWheelZoom: true }); + addFitControl(ZONE_STATE.map, 'Revoir toutes les zones de départ'); + // Calque du tracé d'un appareil, séparé des cellules et des cercles : il est + // vidé et redessiné à chaque sélection sans toucher au reste de la carte. + ZONE_STATE.trackLayer = L.layerGroup().addTo(ZONE_STATE.map); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OSM © CARTO', + subdomains: 'abcd', maxZoom: 19, + }).addTo(ZONE_STATE.map); + + ZONE_STATE.layer = L.layerGroup().addTo(ZONE_STATE.map); + ZONE_STATE.aeroLayer = L.layerGroup().addTo(ZONE_STATE.map); + + // Portée utile de la station : au-delà, les points bas ne sont plus captés + L.circle(center, { + radius: 40000, color: '#58a6ff', weight: 1, opacity: .3, + fillOpacity: .02, dashArray: '6 4', + }).addTo(ZONE_STATE.map); + + drawAirportCircles(); +} + +// Cercles d'exclusion : ce que la recherche écarte +async function drawAirportCircles() { + const airports = await loadAirports(); + ZONE_STATE.aeroLayer.clearLayers(); + const margin = parseFloat(document.getElementById('zon-margin')?.value || 0); + + // Index id → cercle : sans lui, impossible de retrouver le tracé d'une zone + // pour la mettre en évidence depuis le tableau de bilan. + ZONE_STATE.aeroCircles = {}; + + airports.filter(a => a.actif).forEach(a => { + const rayon = (parseFloat(a.rayon_km) + margin) * 1000; + const teinte = zoneCouleur(a.type); + // Le remplissage reprend la couleur de la nature du terrain, celle du + // tableau de bilan : un aéroport civil se lit en gris, une emprise + // militaire en bleu. Le contour reste bordeaux pour tous, car il signifie + // autre chose — l'exclusion de la détection — et doit rester homogène. + const circle = L.circle([a.lat, a.lon], { + radius: rayon, color: '#f85149', weight: 1, opacity: .5, + fillColor: teinte.c, fillOpacity: .12, dashArray: '4 4', + }).bindTooltip(`${a.code} — ${a.nom}
    ` + + `${teinte.lbl} · exclusion : ${a.rayon_km} km` + + `${margin ? ` (+${margin})` : ''}`, + { sticky: true }) + .addTo(ZONE_STATE.aeroLayer); + circle._teinte = teinte.c; // mémorisée pour la mise en évidence + ZONE_STATE.aeroCircles[a.id] = circle; + }); + + // Un redessin efface la mise en évidence : la rétablir si elle porte sur une + // zone toujours présente. + if (ZONE_STATE.highlighted != null) highlightZone(ZONE_STATE.highlighted); +} + +async function loadZones() { + initMapZones(); + const badge = document.getElementById('zon-count'); + const list = document.getElementById('zon-list'); + + list.innerHTML = '
    Analyse…
    '; + await drawAirportCircles(); + + const params = new URLSearchParams({ + action: 'departures_offfield_zones', + from: depLocalToParam(document.getElementById('zon-from').value), + window: document.getElementById('zon-window').value, + alt_max: document.getElementById('zon-altmax').value, + margin: document.getElementById('zon-margin').value, + cell: document.getElementById('zon-cell').value, + min_events: document.getElementById('zon-minevt').value, + }); + rxApply('zon', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const zones = d.zones || []; + badge.textContent = `${d.count} zone(s)`; + ZONE_STATE.layer.clearLayers(); + + if (!zones.length) { + list.innerHTML = '
    Aucune zone retenue.
    ' + + 'Baisse le seuil « Min. évts » ou élargis la période.
    '; + return; + } + + const maxEvt = Math.max(...zones.map(z => Number(z.evenements))); + + zones.forEach((z, i) => { + const evt = Number(z.evenements); + const ratio = evt / maxEvt; + // Rayon proportionnel à la racine du compte : l'aire reste lisible + const rad = 400 + Math.sqrt(ratio) * 2200; + const color = ratio > .66 ? '#f85149' : (ratio > .33 ? '#d29922' : '#3fb950'); + + const circ = L.circle([z.lat, z.lon], { + radius: rad, color, weight: 2, opacity: .9, + fillColor: color, fillOpacity: .35, + }); + // Clic → panneau de détail sous la carte (un popup Leaflet est trop + // étroit pour un tableau d'aéronefs) + circ.on('click', () => showZoneDetail(z, i + 1, rad / 1000)); + circ.bindTooltip( + `Zone ${i + 1} — ${evt} événement(s), ` + + `${z.appareils} appareil(s)
    ` + + `Clique pour le détail`, { sticky: true }); + circ.addTo(ZONE_STATE.layer); + + }); + + list.innerHTML = zones.slice(0, 25).map((z, i) => { + const evt = Number(z.evenements); + const ratio = evt / maxEvt; + const color = ratio > .66 ? '#f85149' : (ratio > .33 ? '#d29922' : '#3fb950'); + return `
    + Zone ${i + 1} + + ${Number(z.lat).toFixed(3)}, ${Number(z.lon).toFixed(3)} + ${evt} +
    `; + }).join('') + + (zones.length > 25 + ? `
    + +${zones.length - 25} autre(s) zone(s) affichée(s) sur la carte
    ` + : ''); + + } catch (e) { + console.error('zones:', e); + list.innerHTML = `
    Erreur : ${e.message}
    `; + badge.textContent = '0 zone(s)'; + } +} + +// ── Câblage ────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + const depFrom = document.getElementById('dep-from'); + const zonFrom = document.getElementById('zon-from'); + if (depFrom) depFrom.value = depDefaultFrom(10080); // 1 semaine + if (zonFrom) zonFrom.value = depDefaultFrom(20160); // 2 semaines par défaut + + document.getElementById('dep-go')?.addEventListener('click', loadDepartures); + // Le panneau de droite s'affiche d'emblée mais reste vide de chiffres : sans + // repère, on peut ne pas comprendre qu'une action est attendue. L'animation + // signale le bouton jusqu'au premier clic, puis disparaît définitivement — + // la mémoriser évite de reprendre l'utilisateur par la manche à chaque visite. + const zonGo = document.getElementById('zon-go'); + try { + if (zonGo && !localStorage.getItem('zonGoClicked')) { + zonGo.classList.add('btn-attention'); + } + } catch (e) { /* stockage indisponible : l'animation reste, sans gravité */ } + + zonGo?.addEventListener('click', () => { + zonGo.classList.remove('btn-attention'); + try { localStorage.setItem('zonGoClicked', '1'); } catch (e) {} + loadZones(); + loadZoneBilan(); // le bilan suit les mêmes paramètres que l'analyse + }); + + // La période choisie repositionne la date de début, pour éviter de chercher + // dans une fenêtre qui déborde dans le futur + document.getElementById('dep-window')?.addEventListener('change', e => { + if (depFrom) depFrom.value = depDefaultFrom(parseInt(e.target.value, 10)); + loadReceivers('dep', true); // les sources dépendent de la fenêtre + }); + document.getElementById('dep-from') + ?.addEventListener('change', () => loadReceivers('dep', true)); + + // Sources chargées à l'ouverture de l'onglet, pas au chargement de la page : + // la requête agrège les points de la période et n'a rien à faire tant que + // l'utilisateur n'est pas sur l'onglet. + document.querySelector('a[href="#tab-depart-liste"]') + ?.addEventListener('shown.bs.tab', () => loadReceivers('dep', false)); + document.querySelector('a[href="#tab-depart-zones"]') + ?.addEventListener('shown.bs.tab', () => { + loadReceivers('zon', false); + renderZoneBilanSkeleton(); + // La carte partage désormais la largeur avec la colonne de bilan : + // Leaflet doit être averti, sinon il conserve les dimensions calculées + // à l'initialisation et laisse une bande de tuiles grises. + setTimeout(() => ZONE_STATE.map?.invalidateSize(), 80); + }); + document.getElementById('zon-window') + ?.addEventListener('change', () => loadReceivers('zon', true)); + document.getElementById('zon-from') + ?.addEventListener('change', () => loadReceivers('zon', true)); + document.getElementById('zon-window')?.addEventListener('change', e => { + if (zonFrom) zonFrom.value = depDefaultFrom(parseInt(e.target.value, 10)); + }); + + document.getElementById('zon-show-aero')?.addEventListener('change', e => { + if (!ZONE_STATE.map) return; + if (e.target.checked) ZONE_STATE.aeroLayer.addTo(ZONE_STATE.map); + else ZONE_STATE.map.removeLayer(ZONE_STATE.aeroLayer); + }); + + document.getElementById('zon-margin')?.addEventListener('change', drawAirportCircles); + + // Leaflet a besoin que le conteneur soit visible pour calculer sa taille + document.querySelector('a[href="#tab-depart-zones"]') + ?.addEventListener('shown.bs.tab', () => { + initMapZones(); + setTimeout(() => ZONE_STATE.map?.invalidateSize(), 100); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// GESTION DES ZONES D'EXCLUSION — création, édition, suppression +// +// Deux origines de fiches : +// reference : aérodromes livrés avec le stack. Rayon et état modifiables, +// suppression impossible (désactivation à la place) — on ne veut +// pas perdre un terrain officiel par une fausse manœuvre. +// manuelle : zones ajoutées ici, entièrement modifiables et supprimables. +// ═══════════════════════════════════════════════════════════════════════════════ + +const ZCRUD = { filter: 'all', rows: [] }; + +function zformMsg(txt, type = 'secondary') { + const el = document.getElementById('zform-msg'); + if (el) el.innerHTML = txt ? `${txt}` : ''; +} + +function zformReset() { + document.getElementById('zform-id').value = ''; + document.getElementById('zform-source').value = 'manuelle'; + document.getElementById('zform-nom').value = ''; + document.getElementById('zform-lat').value = ''; + document.getElementById('zform-lon').value = ''; + document.getElementById('zform-rayon').value = 5; + document.getElementById('zform-type').value = 'inconnu'; + document.getElementById('zform-comm').value = ''; + document.getElementById('zform-actif').checked = true; + document.getElementById('zform-title').innerHTML = + ' Nouvelle zone d\'exclusion'; + document.getElementById('zform-cancel').classList.add('d-none'); + ['zform-nom','zform-lat','zform-lon','zform-type'].forEach(id => { + document.getElementById(id).disabled = false; + }); + zformMsg(''); +} + +// Pré-remplit le formulaire depuis une fiche existante +function zformEdit(id) { + const a = ZCRUD.rows.find(r => Number(r.id) === Number(id)); + if (!a) return; + + document.getElementById('zform-id').value = a.id; + document.getElementById('zform-source').value = a.source; + document.getElementById('zform-nom').value = a.nom; + document.getElementById('zform-lat').value = a.lat; + document.getElementById('zform-lon').value = a.lon; + document.getElementById('zform-rayon').value = a.rayon_km; + document.getElementById('zform-type').value = a.type; + document.getElementById('zform-comm').value = a.commentaire || ''; + document.getElementById('zform-actif').checked = a.actif; + + const ref = a.source === 'reference'; + document.getElementById('zform-title').innerHTML = + ` Modification — ${a.code} ${a.nom}` + + (ref ? ' référence : rayon et état seulement' : ''); + + // Sur un aérodrome officiel, nom/coordonnées/type restent figés + ['zform-nom','zform-lat','zform-lon','zform-type'].forEach(f => { + document.getElementById(f).disabled = ref; + }); + + document.getElementById('zform-cancel').classList.remove('d-none'); + document.querySelector('#modal-zones .modal-body').scrollTop = 0; +} + +async function zformSave() { + const id = document.getElementById('zform-id').value; + const nom = document.getElementById('zform-nom').value.trim(); + const lat = document.getElementById('zform-lat').value; + const lon = document.getElementById('zform-lon').value; + + if (!id && (!nom || lat === '' || lon === '')) { + zformMsg('Nom, latitude et longitude sont requis.', 'danger'); + return; + } + + const body = new URLSearchParams({ + action: 'airport_save', + id: id, + nom: nom, + lat: lat, + lon: lon, + rayon_km: document.getElementById('zform-rayon').value, + type: document.getElementById('zform-type').value, + commentaire: document.getElementById('zform-comm').value.trim(), + actif: document.getElementById('zform-actif').checked ? '1' : '0', + }); + + zformMsg('Enregistrement…'); + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + zformMsg(d.mode === 'insert' + ? `Zone créée (code ${d.code}).` + : 'Zone mise à jour.', 'success'); + zformReset(); + DEP_STATE.airports = []; // invalide le cache + await loadZoneList(); + if (ZONE_STATE.initialized) await drawAirportCircles(); + } catch (e) { + zformMsg('Erreur : ' + e.message, 'danger'); + } +} + +async function zoneDelete(id) { + const a = ZCRUD.rows.find(r => Number(r.id) === Number(id)); + if (!a) return; + + const ref = a.source === 'reference'; + const msg = ref + ? `« ${a.nom} » est un aérodrome de référence.\n\n` + + `Il sera DÉSACTIVÉ (retiré de l'exclusion) mais conservé, ` + + `pour pouvoir être réactivé plus tard.\n\nContinuer ?` + : `Supprimer définitivement la zone « ${a.nom} » ?`; + if (!confirm(msg)) return; + + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'airport_delete', id }), + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + DEP_STATE.airports = []; + await loadZoneList(); + if (ZONE_STATE.initialized) await drawAirportCircles(); + } catch (e) { + alert('Erreur : ' + e.message); + } +} + +async function zoneToggle(id) { + try { + await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'airport_toggle', id }), + }); + DEP_STATE.airports = []; + await loadZoneList(); + if (ZONE_STATE.initialized) await drawAirportCircles(); + } catch (e) { + alert('Erreur : ' + e.message); + } +} + +// Modification du rayon directement depuis la ligne du tableau +async function zoneSetRadius(id, rayon) { + const a = ZCRUD.rows.find(r => Number(r.id) === Number(id)); + if (!a) return; + const v = parseFloat(rayon); + if (!(v > 0 && v <= 100)) { await loadZoneList(); return; } + + const body = new URLSearchParams({ + action: 'airport_save', id, + nom: a.nom, lat: a.lat, lon: a.lon, + rayon_km: v, type: a.type, + commentaire: a.commentaire || '', + actif: a.actif ? '1' : '0', + }); + try { + await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + DEP_STATE.airports = []; + await loadZoneList(); + if (ZONE_STATE.initialized) await drawAirportCircles(); + } catch (e) { + alert('Erreur : ' + e.message); + } +} + +async function loadZoneList() { + const tbody = document.getElementById('zlist-tbody'); + const count = document.getElementById('zlist-count'); + if (!tbody) return; + + try { + const r = await fetch('api.php?action=airports'); + const d = await r.json(); + ZCRUD.rows = d.airports || []; + } catch (e) { + tbody.innerHTML = ` + Erreur : ${e.message}`; + return; + } + + const rows = ZCRUD.rows.filter(a => + ZCRUD.filter === 'all' || a.source === ZCRUD.filter); + + const actives = ZCRUD.rows.filter(a => a.actif).length; + count.textContent = `${ZCRUD.rows.length} zone(s) — ${actives} active(s) ` + + `— ${ZCRUD.rows.filter(a => a.source === 'manuelle').length} ajoutée(s) à la main`; + + if (!rows.length) { + tbody.innerHTML = '' + + 'Aucune zone dans cette catégorie'; + return; + } + + tbody.innerHTML = rows.map(a => { + const ref = a.source === 'reference'; + return ` + +
    + +
    + + ${a.code} + ${a.nom} + ${ref ? '' : 'manuelle'} + ${a.type} + ${Number(a.lat).toFixed(4)}, ${Number(a.lon).toFixed(4)} + + + + + + ${(a.commentaire || '—').slice(0, 60)} + + + + + `; + }).join(''); +} + +// Ajout d'une zone découverte directement depuis la carte +async function excludeDiscoveredZone(lat, lon, evenements, rayonKm) { + const nom = prompt( + `Nom de la zone à exclure ?\n\n` + + `Position : ${Number(lat).toFixed(5)}, ${Number(lon).toFixed(5)}\n` + + `${evenements} événement(s) détecté(s) ici`, + `Zone ${Number(lat).toFixed(3)} / ${Number(lon).toFixed(3)}`); + if (!nom) return; + + const rayon = prompt('Rayon d\'exclusion en km ?', rayonKm || 3); + if (rayon === null) return; + + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'airport_save', + nom, lat, lon, + rayon_km: rayon, + type: 'inconnu', + commentaire: `Découverte par l'analyse — ${evenements} événement(s)`, + actif: '1', + }), + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + DEP_STATE.airports = []; + await drawAirportCircles(); + await loadZones(); // relance l'analyse, la zone disparaît des résultats + } catch (e) { + alert('Erreur : ' + e.message); + } +} + +// ── Câblage de la modale ───────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('modal-zones') + ?.addEventListener('show.bs.modal', () => { zformReset(); loadZoneList(); }); + + document.getElementById('zform-save')?.addEventListener('click', zformSave); + document.getElementById('zform-cancel')?.addEventListener('click', zformReset); + + document.querySelectorAll('.zlist-filter').forEach(b => { + b.addEventListener('click', () => { + document.querySelectorAll('.zlist-filter').forEach(x => x.classList.remove('active')); + b.classList.add('active'); + ZCRUD.filter = b.dataset.filter; + loadZoneList(); + }); + }); + + document.getElementById('zlist-apply')?.addEventListener('click', () => { + bootstrap.Modal.getInstance(document.getElementById('modal-zones'))?.hide(); + if (ZONE_STATE.initialized) loadZones(); + }); + + // ── Réglage groupé des rayons ──────────────────────────────────────────── + document.getElementById('zbulk-mode')?.addEventListener('change', zbulkReset); + document.getElementById('zbulk-value')?.addEventListener('input', zbulkReset); + document.getElementById('zbulk-scope')?.addEventListener('change', zbulkReset); + document.getElementById('zbulk-preview')?.addEventListener('click', zbulkPreview); + document.getElementById('zbulk-apply')?.addEventListener('click', zbulkApply); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BILAN PAR ZONE CONNUE +// +// Miroir de la carte : celle-ci agrège les départs qui ne tombent dans AUCUNE +// zone d'exclusion, ce tableau compte ceux qui y tombent, zone par zone. Les +// deux ensembles sont disjoints et couvrent la totalité des départs détectés. +// +// La couleur code la nature du terrain, pas son activité : on veut distinguer +// d'un coup d'œil un gros aéroport civil d'une emprise militaire. +// ═══════════════════════════════════════════════════════════════════════════════ + +const ZONE_COULEURS = { + aeroport: { c: '#8b949e', lbl: 'Aéroport civil' }, + base: { c: '#58a6ff', lbl: 'Militaire' }, + militaire: { c: '#58a6ff', lbl: 'Militaire' }, + aerodrome: { c: '#3fb950', lbl: 'Aérodrome' }, + heliport: { c: '#bc8cff', lbl: 'Héliport' }, + helisurface: { c: '#bc8cff', lbl: 'Hélisurface' }, + ulm: { c: '#3fb950', lbl: 'ULM' }, + inconnu: { c: '#6e7681', lbl: 'Indéterminé' }, +}; + +function zoneCouleur(type) { + return ZONE_COULEURS[(type || 'inconnu').toLowerCase()] || ZONE_COULEURS.inconnu; +} + +// Tableau affiché AVANT toute analyse : la liste des zones connues, sans +// comptage. Il vient de adsb.airports, requête négligeable, alors que le bilan +// chiffré relance la détection complète — d'où la séparation. L'utilisateur +// voit ainsi la structure du panneau et comprend ce que l'analyse va remplir. +async function renderZoneBilanSkeleton() { + const box = document.getElementById('zbil-list'); + if (!box || ZONE_STATE.bilanCalcule) return; + + try { + const zones = (await loadAirports()).filter(z => z.actif); + if (!zones.length) { + box.innerHTML = '
    Aucune zone d\'exclusion active.
    '; + return; + } + + const ordre = { aeroport: 0, base: 1, militaire: 1, aerodrome: 2, + heliport: 3, helisurface: 3, ulm: 4, inconnu: 5 }; + zones.sort((a, b) => (ordre[(a.type || '').toLowerCase()] ?? 9) + - (ordre[(b.type || '').toLowerCase()] ?? 9) + || Number(b.rayon_km) - Number(a.rayon_km)); + + box.innerHTML = + '
    ' + + 'ZONES CONNUES — CLIQUE SUR « ANALYSER » POUR LES CHIFFRES
    ' + + zones.map(z => { + const t = zoneCouleur(z.type); + return `
    +
    + + + ${esc(z.nom)} + + +
    +
    + ${esc(z.code)} · ${t.lbl} · ${z.rayon_km} km
    +
    `; + }).join(''); + + bindZoneBilanRows(box); + } catch (e) { + console.error('squelette bilan:', e); + } +} + +// Câblage commun au squelette et au bilan chiffré : même comportement au clic, +// une seule implémentation. +function bindZoneBilanRows(box) { + box.querySelectorAll('.zbil-row').forEach(el => { + el.addEventListener('click', () => { + box.querySelectorAll('.zbil-row').forEach(x => x.classList.remove('zbil-active')); + el.classList.add('zbil-active'); + + const lat = parseFloat(el.dataset.lat), lon = parseFloat(el.dataset.lon); + if (ZONE_STATE.map && isFinite(lat)) ZONE_STATE.map.setView([lat, lon], 12); + + const zid = el.dataset.zid; + if (zid && zid !== 'null') highlightZone(zid); + else clearZoneHighlight(); + + loadZoneAircraft(zid || 'null', el.dataset.nom || '', + el.dataset.couleur || '#d29922'); + }); + }); +} + +async function loadZoneBilan() { + const box = document.getElementById('zbil-list'); + const per = document.getElementById('zbil-period'); + if (!box) return; + + box.innerHTML = '
    ' + + ' Calcul…
    '; + document.getElementById('zbil-detail')?.classList.add('d-none'); + clearZoneHighlight(); + effacerTrace(); + + const params = new URLSearchParams({ + action: 'departures_by_zone', + from: depLocalToParam(document.getElementById('zon-from').value), + window: document.getElementById('zon-window').value, + alt_max: document.getElementById('zon-altmax').value, + margin: document.getElementById('zon-margin').value, + }); + rxApply('zon', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const zones = (d.zones || []); + const hors = d.hors_zone || {}; + const maxi = Math.max(1, ...zones.map(z => Number(z.departs) || 0), + Number(hors.departs) || 0); + + if (per) { + const lbl = document.getElementById('zon-window') + ?.selectedOptions?.[0]?.textContent || ''; + per.textContent = lbl.trim(); + } + + // Ligne de synthèse : les départs hors de toute zone, c'est-à-dire ceux + // que la carte affiche. La placer en tête donne le point de comparaison. + const nHors = Number(hors.departs) || 0; + let html = ligneBilan({ + nom: 'Hors de toute zone connue', + code: '—', + type: null, + departs: nHors, + appareils: hors.appareils, + dernier: hors.dernier, + couleur: '#d29922', + titre: 'Départs détectés en dehors des zones d\'exclusion actives — ' + + 'ce sont ceux que la carte agrège en cellules', + }, maxi); + + html += '
    ' + + 'ZONES CONNUES
    '; + + // Les zones sans aucun départ sont conservées en fin de liste : une zone + // muette est une information, notamment sur une emprise militaire. + const actives = zones.filter(z => Number(z.departs) > 0); + const muettes = zones.filter(z => !Number(z.departs)); + + html += actives.map(z => ligneBilan({ + id: z.id, nom: z.nom, code: z.code, type: z.type, + departs: Number(z.departs), appareils: z.appareils, dernier: z.dernier, + rayon: z.rayon_km, lat: z.lat, lon: z.lon, + alt_moy: z.alt_moy, vrate_moy: z.vrate_moy, + couleur: zoneCouleur(z.type).c, + }, maxi)).join(''); + + if (muettes.length) { + html += `
    + ${muettes.length} ZONE(S) SANS DÉPART DÉTECTÉ
    `; + html += '
    ' + muettes.map(z => + `${esc(z.code)}`).join('') + '
    '; + } + + box.innerHTML = html; + + // Clic sur une ligne : recadrage, mise en évidence du cercle, et liste des + // six derniers appareils. + bindZoneBilanRows(box); + ZONE_STATE.bilanCalcule = true; // le squelette ne doit plus l'écraser + + } catch (e) { + console.error('bilan zones:', e); + box.innerHTML = `
    Erreur : ${esc(e.message)}
    `; + } +} + +function ligneBilan(z, maxi) { + const pct = Math.round((z.departs / maxi) * 100); + const cliquable = z.lat != null; + const tip = z.titre || [ + z.code !== '—' ? z.code : null, + z.rayon ? `rayon ${z.rayon} km` : null, + z.alt_moy ? `altitude moyenne ${Number(z.alt_moy).toLocaleString('fr-FR')} ft` : null, + z.vrate_moy ? `montée moyenne ${Number(z.vrate_moy).toLocaleString('fr-FR')} ft/min` : null, + z.dernier ? `dernier le ${z.dernier}` : null, + ].filter(Boolean).join(' · '); + + return `
    +
    + + + ${esc(z.nom)} + + ${z.departs} +
    +
    + + ${z.appareils ? z.appareils + ' appareil(s)' : '—'} + ${z.dernier ? ' · ' + z.dernier : ''} +
    +
    +
    +
    +
    `; +} + +// ── Mise en évidence d'une zone sur la carte ───────────────────────────────── +// Le style normal des exclusions est un rouge sombre discret. La zone +// sélectionnée passe en rouge clair, trait plein et épaissi : il faut qu'elle +// se distingue au milieu de seize cercles qui se chevauchent. +// Le remplissage variant d'un cercle à l'autre, les styles ne peuvent plus être +// des constantes : ils se calculent à partir de la teinte mémorisée. +function zoneStyleNormal(c) { + return { + color: '#f85149', weight: 1, opacity: .5, + fillColor: c._teinte || '#f85149', fillOpacity: .12, dashArray: '4 4', + }; +} +function zoneStyleHighlight(c) { + return { + color: '#ffb3ad', weight: 3, opacity: 1, + fillColor: c._teinte || '#ffb3ad', fillOpacity: .30, dashArray: null, + }; +} + +function highlightZone(id) { + const circles = ZONE_STATE.aeroCircles || {}; + Object.entries(circles).forEach(([zid, c]) => { + const on = String(zid) === String(id); + c.setStyle(on ? zoneStyleHighlight(c) : zoneStyleNormal(c)); + if (on && c.bringToFront) c.bringToFront(); + }); + ZONE_STATE.highlighted = id; +} + +function clearZoneHighlight() { + Object.values(ZONE_STATE.aeroCircles || {}) + .forEach(c => c.setStyle(zoneStyleNormal(c))); + ZONE_STATE.highlighted = null; +} + +// ── Tracé d'un appareil sur la carte des zones ─────────────────────────────── +// Distinct du panneau de trajectoire de l'onglet liste : ici le chemin est +// dessiné sur la carte principale, au milieu des zones, pour qu'on voie d'où +// l'appareil vient et où il va par rapport aux terrains connus. +async function traceSurCarte(icao, ts, libelle) { + if (!ZONE_STATE.map || !ZONE_STATE.trackLayer) return; + + ZONE_STATE.trackLayer.clearLayers(); + ZONE_STATE.trackIcao = icao; + + try { + const r = await fetch('api.php?' + new URLSearchParams({ + action: 'aircraft_track', icao, ts: ts || '', before: 15, after: 45, + })); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const pts = (d.track || d.points || []).filter(p => p.lat != null && p.lon != null); + if (!pts.length) { + ZONE_STATE.trackLayer.clearLayers(); + return; + } + + const latlngs = pts.map(p => [Number(p.lat), Number(p.lon)]); + + // Halo sombre sous le tracé : sans lui, une ligne claire se perd sur le + // remplissage clair d'une zone. + L.polyline(latlngs, { color: '#0d1117', weight: 7, opacity: .55 }) + .addTo(ZONE_STATE.trackLayer); + L.polyline(latlngs, { color: '#39d353', weight: 3, opacity: .95 }) + .addTo(ZONE_STATE.trackLayer); + + // Point de départ (le plus ancien) et dernière position connue + const p0 = pts[0], pN = pts[pts.length - 1]; + L.circleMarker([p0.lat, p0.lon], { + radius: 6, color: '#fff', weight: 2, + fillColor: '#39d353', fillOpacity: 1, + }).bindTooltip(`Début — ${p0.hhmm || ''} · ` + + `${Number(p0.altitude).toLocaleString('fr-FR')} ft`) + .addTo(ZONE_STATE.trackLayer); + + L.circleMarker([pN.lat, pN.lon], { + radius: 6, color: '#fff', weight: 2, + fillColor: '#f85149', fillOpacity: 1, + }).bindTooltip(`Fin — ${pN.hhmm || ''} · ` + + `${Number(pN.altitude).toLocaleString('fr-FR')} ft`) + .addTo(ZONE_STATE.trackLayer); + + L.polyline(latlngs).bindTooltip( + `${esc(icao)}${libelle ? ' — ' + esc(libelle) : ''} · ${pts.length} points`, + { sticky: true }).addTo(ZONE_STATE.trackLayer); + + // Le tracé peut sortir largement du cadrage courant : on l'amène à l'écran + // sans zoomer à l'excès sur un tracé très court. + ZONE_STATE.map.fitBounds(L.latLngBounds(latlngs), + { padding: [40, 40], maxZoom: 12 }); + } catch (e) { + console.error('trace carte:', e); + } +} + +function effacerTrace() { + ZONE_STATE.trackLayer?.clearLayers(); + ZONE_STATE.trackIcao = null; + document.querySelectorAll('.zav-row.zav-active') + .forEach(x => x.classList.remove('zav-active')); +} + +// ── Six derniers appareils d'une zone ──────────────────────────────────────── +// État du second tableau. La profondeur est mémorisée hors de la fonction +// pour survivre aux re-rendus : sans cela, allonger la liste puis cliquer un +// appareil la remettrait à 8. +const ZAV = { zoneId: null, nom: '', couleur: '#d29922', + limit: 8, step: 8, rows: [] }; + +async function loadZoneAircraft(zoneId, nom, couleur, garderProfondeur) { + const box = document.getElementById('zbil-detail'); + if (!box) return; + + // Changer de zone remet la profondeur au défaut ; l'allonger ne doit pas. + if (!garderProfondeur) ZAV.limit = ZAV.step; + ZAV.zoneId = zoneId; ZAV.nom = nom; ZAV.couleur = couleur; + + box.classList.remove('d-none'); + box.innerHTML = '
    ' + + ' Chargement…
    '; + + const params = new URLSearchParams({ + action: 'zone_aircraft', + zone_id: zoneId, + from: depLocalToParam(document.getElementById('zon-from').value), + window: document.getElementById('zon-window').value, + alt_max: document.getElementById('zon-altmax').value, + margin: document.getElementById('zon-margin').value, + // On demande une ligne de plus que nécessaire : la réponse dit ainsi s'il + // reste quelque chose à afficher, sans requête de comptage séparée. + limit: ZAV.limit + 1, + }); + rxApply('zon', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const recus = d.aircraft || []; + const encore = recus.length > ZAV.limit; + ZAV.rows = recus.slice(0, ZAV.limit); + renderZoneAircraft(encore); + } catch (e) { + console.error('zone_aircraft:', e); + box.innerHTML = `
    Erreur : ${esc(e.message)}
    `; + } +} + +function renderZoneAircraft(encore) { + const box = document.getElementById('zbil-detail'); + if (!box) return; + const rows = ZAV.rows; + + let html = `
    + + ${esc(ZAV.nom)} +
    + + ${rows.length} + + +
    +
    `; + + if (!rows.length) { + html += '
    Aucun départ détecté sur la ' + + 'période pour cette zone.
    '; + } else { + html += '
    ' + + 'CLIQUE UN APPAREIL POUR VOIR SON CHEMIN SUR LA CARTE
    '; + html += rows.map((a, i) => { + const typ = a.aircraft_desc || a.aircraft_type || ''; + // Le premier caractère du code descriptif porte la voilure : + // H pour hélicoptère, L pour voilure fixe terrestre. + const helico = typ.toUpperCase().startsWith('H'); + const icone = helico ? 'bi-fan' : 'bi-airplane'; + const vr = a.vrate != null + ? (a.vrate > 0 ? '+' : '') + Number(a.vrate).toLocaleString('fr-FR') + : '—'; + const actif = ZONE_STATE.trackIcao === a.icao ? ' zav-active' : ''; + return `
    +
    + + + ${esc(a.icao)} + ${a.callsign ? '' + + esc(a.callsign.trim()) + '' : ''} + + ${a.ts_local || ''} +
    +
    + ${esc(typ) || '—'} + ${a.operator_name ? ' · ' + esc(a.operator_name) : ''} + · ${Number(a.altitude).toLocaleString('fr-FR')} ft + · ${vr} ft/min +
    +
    `; + }).join(''); + + if (ZONE_STATE.trackIcao) { + html += ``; + } + } + + box.innerHTML = html; + + box.querySelectorAll('.zav-row').forEach(el => { + el.addEventListener('click', () => { + const a = ZAV.rows[Number(el.dataset.idx)]; + if (!a) return; + box.querySelectorAll('.zav-row').forEach(x => x.classList.remove('zav-active')); + el.classList.add('zav-active'); + traceSurCarte(a.icao, a.ts, (a.callsign || '').trim()) + .then(() => renderZoneAircraft(encore)); + }); + }); + + document.getElementById('zav-plus')?.addEventListener('click', () => { + ZAV.limit += ZAV.step; + loadZoneAircraft(ZAV.zoneId, ZAV.nom, ZAV.couleur, true); + }); + document.getElementById('zav-moins')?.addEventListener('click', () => { + ZAV.limit = Math.max(ZAV.step, ZAV.limit - ZAV.step); + loadZoneAircraft(ZAV.zoneId, ZAV.nom, ZAV.couleur, true); + }); + document.getElementById('zav-clear')?.addEventListener('click', () => { + effacerTrace(); + renderZoneAircraft(encore); + }); + document.getElementById('zbil-detail-close')?.addEventListener('click', () => { + box.classList.add('d-none'); + effacerTrace(); + clearZoneHighlight(); + document.querySelectorAll('.zbil-row.zbil-active') + .forEach(x => x.classList.remove('zbil-active')); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RÉGLAGE GROUPÉ DES RAYONS +// +// Persisté en base, contrairement au sélecteur « Marge » de la barre d'outils +// qui n'ajoute des kilomètres qu'au moment de la requête et se réinitialise à +// chaque visite. +// +// La simulation est obligatoire avant l'application : une opération groupée sur +// les rayons est difficile à défaire à la main, et le mode « fixer » écrase la +// hiérarchie entre un aéroport à 15 km et une hélisurface à 300 m. +// ═══════════════════════════════════════════════════════════════════════════════ + +function zbulkScopeRows(scope) { + const rows = ZCRUD.rows || []; + if (scope === 'actives') return rows.filter(z => z.actif); + if (scope === 'reference') return rows.filter(z => z.source === 'reference'); + if (scope === 'manuelle') return rows.filter(z => z.source === 'manuelle'); + return rows; +} + +// Reproduit le bornage appliqué côté SQL, pour que la simulation ne mente pas. +function zbulkNewRadius(cur, mode, val) { + let r = mode === 'set' ? val + : mode === 'delta' ? cur + val + : cur * val; + return Math.min(100, Math.max(0.1, r)); +} + +function zbulkReset() { + document.getElementById('zbulk-apply').disabled = true; + document.getElementById('zbulk-preview-box').classList.add('d-none'); + document.getElementById('zbulk-msg').innerHTML = ''; +} + +function zbulkPreview() { + const mode = document.getElementById('zbulk-mode').value; + const val = parseFloat(document.getElementById('zbulk-value').value); + const scope = document.getElementById('zbulk-scope').value; + const box = document.getElementById('zbulk-preview-box'); + const msg = document.getElementById('zbulk-msg'); + + if (!isFinite(val)) { + msg.innerHTML = 'Valeur numérique attendue.'; + return; + } + if (mode === 'set' && (val <= 0 || val > 100)) { + msg.innerHTML = 'Rayon hors bornes (0 < r ≤ 100 km).'; + return; + } + if (mode === 'factor' && (val <= 0 || val > 20)) { + msg.innerHTML = 'Facteur hors bornes (0 < f ≤ 20).'; + return; + } + + const rows = zbulkScopeRows(scope); + if (!rows.length) { + msg.innerHTML = 'Aucune zone dans cette portée.'; + return; + } + + let borne = 0; + const lignes = rows.map(z => { + const cur = Number(z.rayon_km); + const nv = zbulkNewRadius(cur, mode, val); + const cl = nv > cur ? 'text-warning' : (nv < cur ? 'text-info' : 'text-secondary'); + // Une zone déjà au plancher ou au plafond ne bougera pas : le signaler + // évite de croire que l'opération a échoué. + const clamp = (mode !== 'set' && Math.abs(nv - (mode === 'delta' ? cur + val : cur * val)) > 0.001); + if (clamp) borne++; + return `
    + ${esc(z.code)} ${esc(z.nom)} + ${cur.toFixed(1)} → ${nv.toFixed(1)} km + ${clamp ? '' : ''} +
    `; + }).join(''); + + box.innerHTML = lignes; + box.classList.remove('d-none'); + + const avert = mode === 'set' + ? ' Ce mode écrase la hiérarchie des rayons.' : ''; + const bAvert = borne + ? ` ${borne} zone(s) bornée(s) à 0,1 ou 100 km.` : ''; + msg.innerHTML = `${rows.length} zone(s) concernée(s).` + + avert + bAvert; + + document.getElementById('zbulk-apply').disabled = false; +} + +async function zbulkApply() { + const mode = document.getElementById('zbulk-mode').value; + const val = parseFloat(document.getElementById('zbulk-value').value); + const scope = document.getElementById('zbulk-scope').value; + const msg = document.getElementById('zbulk-msg'); + const n = zbulkScopeRows(scope).length; + + const libelle = mode === 'set' ? `fixer les rayons à ${val} km` + : mode === 'delta' ? `${val >= 0 ? 'ajouter' : 'retirer'} ${Math.abs(val)} km` + : `multiplier les rayons par ${val}`; + if (!confirm(`Confirmer : ${libelle} sur ${n} zone(s) ?\n\n` + + `Cette modification est écrite en base et s'appliquera à toutes ` + + `les analyses ultérieures.`)) return; + + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'airports_bulk_radius', mode, value: val, scope, + }), + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + msg.innerHTML = `${d.updated} zone(s) mise(s) à jour.`; + zbulkReset(); + DEP_STATE.airports = []; // cache des zones invalidé + await loadZoneList(); + if (ZONE_STATE.initialized) { + await drawAirportCircles(); + loadZoneBilan(); // les rayons ont changé, les comptes aussi + } + } catch (e) { + msg.innerHTML = `${esc(e.message)}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PANNEAU DE DÉTAIL D'UNE ZONE DÉCOUVERTE +// +// Au clic sur un cercle de la carte : liste des aéronefs détectés dans cette +// cellule, avec leurs caractéristiques, et bouton d'ajout à la liste +// d'exclusion. Le bouton indique si la zone est DÉJÀ couverte par une +// exclusion existante — auquel cas il ne sert à rien de la rajouter. +// ═══════════════════════════════════════════════════════════════════════════════ + +const ZDETAIL = { + zone: null, rayon: 3, + rows: [], // tous les aéronefs de la cellule + limit: 4, // affichés — augmenté par pas de 4 + step: 4, +}; + +function zdClose() { + document.getElementById('zone-detail')?.classList.add('d-none'); + ZDETAIL.zone = null; +} + +async function showZoneDetail(z, numero, rayonKm) { + const panel = document.getElementById('zone-detail'); + const tbody = document.getElementById('zd-tbody'); + if (!panel) return; + + ZDETAIL.zone = z; + ZDETAIL.rayon = Math.max(1, Math.round(rayonKm * 10) / 10); + + panel.classList.remove('d-none'); + document.getElementById('zd-title').textContent = `Zone ${numero}`; + document.getElementById('zd-pos').textContent = + `${Number(z.lat).toFixed(5)}, ${Number(z.lon).toFixed(5)}`; + document.getElementById('zd-count').textContent = + `${z.evenements} événement(s) — ${z.appareils} appareil(s)`; + document.getElementById('zd-status').innerHTML = ''; + tbody.innerHTML = '' + + ' Chargement…'; + + // Recentrer sans masquer le panneau + ZONE_STATE.map.setView([z.lat, z.lon], Math.max(ZONE_STATE.map.getZoom(), 12)); + + const params = new URLSearchParams({ + action: 'departures_offfield_zone_detail', + lat: z.lat, + lon: z.lon, + from: depLocalToParam(document.getElementById('zon-from').value), + window: document.getElementById('zon-window').value, + alt_max: document.getElementById('zon-altmax').value, + margin: document.getElementById('zon-margin').value, + cell: document.getElementById('zon-cell').value, + }); + rxApply('zon', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + // Zone déjà couverte par une exclusion ? + const btn = document.getElementById('zd-exclude'); + if (d.covered) { + const c = d.covered; + document.getElementById('zd-status').innerHTML = + ` + ${c.actif ? 'Déjà exclue' : 'Exclusion désactivée'} : + ${c.code} — ${c.nom} (${c.dist_km} km) + `; + btn.classList.remove('btn-warning'); + btn.classList.add('btn-outline-secondary'); + btn.innerHTML = ' Ajuster l’exclusion'; + } else { + btn.classList.add('btn-warning'); + btn.classList.remove('btn-outline-secondary'); + btn.innerHTML = ' Exclure cette zone'; + } + + ZDETAIL.rows = d.aircraft || []; + ZDETAIL.limit = ZDETAIL.step; // chaque nouvelle zone repart à 4 + zdRenderRows(); + return; + + } catch (e) { + console.error('zone detail:', e); + tbody.innerHTML = ` + Erreur : ${e.message}`; + } +} + +// Rendu du tableau, borné à ZDETAIL.limit lignes. +// Séparé de showZoneDetail pour pouvoir réafficher sans rappeler l'API : +// les données sont déjà en mémoire, seul le nombre de lignes change. +function zdRenderRows() { + const tbody = document.getElementById('zd-tbody'); + if (!tbody) return; + + const all = ZDETAIL.rows || []; + const shown = document.getElementById('zd-shown'); + const bMore = document.getElementById('zd-more'); + const bAll = document.getElementById('zd-all'); + + if (!all.length) { + tbody.innerHTML = '' + + 'Aucun aéronef listé — la cellule a peut-être été exclue entre-temps' + + ''; + if (shown) shown.textContent = ''; + bMore?.classList.add('d-none'); + bAll?.classList.add('d-none'); + return; + } + + const rows = all.slice(0, ZDETAIL.limit); + const reste = all.length - rows.length; + if (shown) shown.textContent = `${rows.length} / ${all.length}`; + bMore?.classList.toggle('d-none', reste <= 0); + bAll?.classList.toggle('d-none', reste <= 0); + if (bMore) { + bMore.innerHTML = ` ${Math.min(ZDETAIL.step, reste)}`; + } + + // Occurrences calculées sur l'ENSEMBLE des résultats, pas sur les seules + // lignes visibles : le compteur doit rester stable quand on déplie. + const occ = {}; + all.forEach(a => { occ[a.icao] = (occ[a.icao] || 0) + 1; }); + + { + tbody.innerHTML = rows.map((a, i) => { + const alt = a.altitude != null ? Number(a.altitude).toLocaleString('fr-FR') : '—'; + const vr = a.vrate != null ? (a.vrate > 0 ? '+' : '') + a.vrate : '—'; + const rte = (a.origin || a.destination) + ? `${a.origin || '?'} → ${a.destination || '?'}` : ''; + + const n = occ[a.icao]; + const occCls = n >= 5 ? 'bg-danger' : (n >= 3 ? 'bg-warning text-dark' + : (n >= 2 ? 'bg-info text-dark' : 'bg-dark border')); + + const typ = a.aircraft_desc || a.aircraft_type || ''; + const typTip = decodeAcType(typ); + const typCell = typ + ? (typTip + ? `${typ}` + : typ) + : ''; + + const wtcTip = decodeWtc(a.aircraft_wtc); + const wtcCell = a.aircraft_wtc + ? (wtcTip + ? `${a.aircraft_wtc}` + : a.aircraft_wtc) + : ''; + + return ` + ${a.ts_local || '—'} + ${n} + ${a.icao} + ${a.callsign || ''} + ${rte ? `
    ${rte}` : ''} + ${typCell} + ${wtcCell} + ${a.operator_name || ''} + ${countryFlag(a.country)}${a.country || '—'} + ${alt} ft + ${vr} ft/min + ${phaseCell(a)} + ${a.speed != null ? a.speed + ' kt' : '—'} + ${a.track != null ? Math.round(a.track) + '°' : '—'} + ${a.squawk || ''} + + ${Number(a.lat).toFixed(4)}, ${Number(a.lon).toFixed(4)} + ${rxCell('zon', a)} + ${watchBtn(a.icao)} + + + + + `; + }).join(''); + + zdInitTooltips(tbody); + + // Clic sur la ligne : trajectoire. Les liens et les codes à infobulle + // gardent leur comportement propre. + tbody.querySelectorAll('tr.zd-row').forEach(tr => { + tr.addEventListener('click', e => { + if (e.target.closest('a, .zd-code, .watch-btn')) return; + const a = ZDETAIL.rows[Number(tr.dataset.idx)]; + if (a) showTrack('zon', a); + }); + }); + } +} + +// ── Câblage du panneau ─────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('zd-close')?.addEventListener('click', zdClose); + + // Affichage progressif : le tableau démarre à 4 lignes pour rester lisible + // sur un écran partagé avec la carte. + document.getElementById('zd-more')?.addEventListener('click', () => { + ZDETAIL.limit += ZDETAIL.step; + zdRenderRows(); + // bindWatchButtons délègue sur le tbody : rien à réattacher, mais l'état + // visuel des boutons doit être recalculé pour les lignes nouvelles. + if (typeof refreshWatchButtons === 'function') refreshWatchButtons(); + }); + + document.getElementById('zd-all')?.addEventListener('click', () => { + ZDETAIL.limit = ZDETAIL.rows.length; + zdRenderRows(); + if (typeof refreshWatchButtons === 'function') refreshWatchButtons(); + }); + + document.getElementById('zd-exclude')?.addEventListener('click', () => { + const z = ZDETAIL.zone; + if (!z) return; + excludeDiscoveredZone(z.lat, z.lon, z.evenements, ZDETAIL.rayon); + }); + + // Échap ferme le panneau + document.addEventListener('keydown', e => { + if (e.key === 'Escape') zdClose(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// PHASE DE VOL DU CONTACT +// +// Classification calculée côté serveur sur l'ENSEMBLE du contact radio, et non +// sur le seul point retenu comme départ : l'appareil montait-il, descendait-il, +// ou n'a-t-il fait que passer ? +// +// Seuil de 1 000 ft entre les extrémités du contact. En deçà, les variations +// relèvent du bruit barométrique et des corrections de niveau, pas d'une +// intention de monter ou de descendre. +// ═══════════════════════════════════════════════════════════════════════════════ + +const PHASES = { + montee: { lbl: 'Montée', icon: 'arrow-up-right', cls: 'text-success', + tip: "Le contact se termine nettement plus haut qu'il ne commence — décollage probable" }, + descente: { lbl: 'Descente', icon: 'arrow-down-right', cls: 'text-info', + tip: "Le contact se termine nettement plus bas qu'il ne commence — atterrissage probable" }, + palier: { lbl: 'Passage', icon: 'arrow-right', cls: 'text-secondary', + tip: "Moins de 1 000 ft d'écart entre le début et la fin du contact — appareil de passage" }, + bosse: { lbl: 'Montée puis descente', icon: 'graph-up', cls: 'text-warning', + tip: 'Monte puis redescend : vol local complet vu dans un seul contact' }, + creux: { lbl: 'Descente puis montée', icon: 'graph-down', cls: 'text-warning', + tip: "Descend puis remonte : approche interrompue, touch-and-go, ou posé suivi d'un nouveau départ" }, + mixte: { lbl: 'Mixte', icon: 'activity', cls: 'text-warning', + tip: 'Profil en dents de scie : plusieurs montées et descentes dans le contact' }, + indetermine: { lbl: '—', icon: 'dash', cls: 'text-secondary', + tip: 'Contact trop court ou trop pauvre en points pour conclure' }, +}; + +function phaseCell(a) { + const ph = PHASES[a.phase] || PHASES.indetermine; + if (!a.phase || a.phase === 'indetermine') { + return ``; + } + const d = (a.alt_debut != null && a.alt_fin != null) + ? `${Number(a.alt_debut).toLocaleString('fr-FR')} → ${Number(a.alt_fin).toLocaleString('fr-FR')} ft` + : ''; + const mm = (a.alt_min_seg != null && a.alt_max_seg != null) + ? `, min ${Number(a.alt_min_seg).toLocaleString('fr-FR')} / max ${Number(a.alt_max_seg).toLocaleString('fr-FR')} ft` + : ''; + const dur = a.duree_min != null ? `, sur ${a.duree_min} min` : ''; + const tip = `${ph.tip}\n${d}${mm}${dur} — ${a.n_pts_seg || 0} points`; + return `` + + ` ${ph.lbl}`; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONTRÔLE DE CADRAGE DES CARTES +// +// Toutes les cartes partagent le même besoin : revenir à une vue montrant +// l'ensemble du contenu. Plutôt que de câbler ce calcul carte par carte — ce +// qui imposerait de connaître pour chacune le nom du calque porteur des +// données — le contrôle inspecte les calques réellement présents au moment du +// clic. Il suit donc les changements de contenu, y compris ceux à venir. +// ═══════════════════════════════════════════════════════════════════════════════ + +// Parcourt récursivement les calques et retourne l'emprise du contenu. +// Les fonds de tuiles n'ont ni getBounds ni getLatLng : ignorés de fait. +function contentBounds(map) { + let b = null; + const push = (x) => { b = b ? b.extend(x) : L.latLngBounds(x, x); }; + + const walk = (layer) => { + if (!layer || layer.options?.excludeFromFit) return; + if (typeof layer.eachLayer === 'function') { layer.eachLayer(walk); return; } + if (typeof layer.getBounds === 'function') { + const lb = layer.getBounds(); + if (lb && lb.isValid()) { push(lb.getNorthEast()); push(lb.getSouthWest()); } + return; + } + if (typeof layer.getLatLng === 'function') { + const ll = layer.getLatLng(); + if (ll) push(ll); + } + }; + + map.eachLayer(walk); + return (b && b.isValid()) ? b : null; +} + +// Bouton de retour au cadrage global, sous les boutons +/-. La vue courante au +// moment de l'appel sert de repli quand la carte est vide : poser ce contrôle +// juste après la création de la carte, avant tout chargement de données. +function addFitControl(map, title) { + const home = { center: map.getCenter(), zoom: map.getZoom() }; + + const Ctl = L.Control.extend({ + options: { position: 'topleft' }, + onAdd() { + const div = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); + const a = L.DomUtil.create('a', '', div); + a.href = '#'; + a.title = title || "Revoir l'ensemble"; + a.setAttribute('role', 'button'); + a.innerHTML = ''; + a.style.fontSize = '14px'; + a.style.lineHeight = '26px'; + + L.DomEvent.on(a, 'click', (e) => { + L.DomEvent.stop(e); + const b = contentBounds(map); + if (b) { + // Une emprise réduite à un point (un seul avion, une seule zone) + // donnerait un zoom maximal inutilisable : on borne. + map.fitBounds(b, { padding: [30, 30], maxZoom: 13 }); + } else { + map.setView(home.center, home.zoom); + } + }); + // Sans cela le clic traverse jusqu'à la carte et la déplace. + L.DomEvent.disableClickPropagation(div); + L.DomEvent.disableScrollPropagation(div); + return div; + }, + }); + + map.addControl(new Ctl()); + return map; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// LISTES DE SURVEILLANCE +// +// Un appareil est suivi par son adresse ICAO 24 bits. WATCH.icaos est le simple +// ensemble des adresses surveillées, toutes listes confondues : il sert à +// colorer les boutons des tableaux de départs sans avoir à rapatrier le détail +// des listes à chaque rendu. +// ═══════════════════════════════════════════════════════════════════════════════ + +const WATCH = { lists: [], icaos: new Set(), items: [], loaded: false }; + +async function loadWatchLists() { + try { + const r = await fetch('api.php?action=watchlists'); + const d = await r.json(); + if (d.error) throw new Error(d.error); + WATCH.lists = d.lists || []; + } catch (e) { + console.error('watchlists:', e); + WATCH.lists = []; + } + fillWatchListSelectors(); + return WATCH.lists; +} + +async function loadWatchIcaos() { + try { + const r = await fetch('api.php?action=watchlist_icaos'); + const d = await r.json(); + WATCH.icaos = new Set(d.icaos || []); + WATCH.loaded = true; + } catch (e) { + console.error('watchlist_icaos:', e); + } + const badge = document.getElementById('watch-nav-count'); + if (badge) { + badge.textContent = WATCH.icaos.size; + badge.classList.toggle('d-none', WATCH.icaos.size === 0); + } + return WATCH.icaos; +} + +function fillWatchListSelectors() { + const opts = WATCH.lists.map(l => + ``).join(''); + + const sel = document.getElementById('wat-list'); + if (sel) { + const keep = sel.value; + sel.innerHTML = '' + opts; + if ([...sel.options].some(o => o.value === keep)) sel.value = keep; + } + const add = document.getElementById('wadd-list'); + if (add) { + const keep = add.value; + add.innerHTML = opts || ''; + if ([...add.options].some(o => o.value === keep)) add.value = keep; + } +} + +// ── Bouton de bascule dans les tableaux de départs ─────────────────────────── +// Deux états : hors surveillance (jumelles creuses) et surveillé (jumelles +// pleines, ambre). Le second clic retire l'appareil de TOUTES les listes — +// depuis un tableau de départs on ne voit pas dans laquelle il figure, et +// proposer un choix à cet endroit alourdirait la ligne pour rien. +function watchBtn(icao) { + const on = WATCH.icaos.has(icao); + return ``; +} + +// Câblage délégué : les tableaux sont réécrits en entier à chaque recherche, +// un écouteur sur le tbody survit à ces reconstructions. +function bindWatchButtons(tbody, rowsProvider) { + tbody.addEventListener('click', async e => { + const btn = e.target.closest('.watch-btn'); + if (!btn) return; + e.stopPropagation(); // ne pas déclencher l'ouverture de trajectoire + + const icao = btn.dataset.icao; + if (btn.dataset.on === '1') { + if (!confirm(`Retirer ${icao} de toutes les listes de surveillance ?`)) return; + await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'watchlist_remove', icao }), + }); + await loadWatchIcaos(); + await loadWatchLists(); + refreshWatchButtons(); + } else { + const row = rowsProvider().find(a => a.icao === icao) || { icao }; + openWatchModal(row); + } + }); +} + +// Met à jour l'apparence des boutons déjà affichés, sans relancer les requêtes +function refreshWatchButtons() { + document.querySelectorAll('.watch-btn').forEach(btn => { + const on = WATCH.icaos.has(btn.dataset.icao); + btn.dataset.on = on ? 1 : 0; + btn.classList.toggle('btn-warning', on); + btn.classList.toggle('btn-outline-secondary', !on); + btn.title = on ? 'Surveillé — cliquer pour retirer' : 'Mettre sous surveillance'; + btn.innerHTML = ``; + }); +} + +// ── Modale d'ajout ─────────────────────────────────────────────────────────── +function openWatchModal(a) { + document.getElementById('wadd-icao').value = a.icao; + document.getElementById('wadd-icao-lbl').textContent = a.icao; + document.getElementById('wadd-callsign').textContent = (a.callsign || '').trim() || '—'; + document.getElementById('wadd-desc').textContent = + [a.aircraft_type, a.aircraft_desc].filter(Boolean).join(' — ') || ''; + document.getElementById('wadd-oper').textContent = + [a.operator_name, a.country].filter(Boolean).join(' · '); + document.getElementById('wadd-motif').value = ''; + document.getElementById('wadd-msg').innerHTML = ''; + + // Métadonnées mémorisées pour l'instantané envoyé au serveur + WATCH.pending = a; + + if (typeof bootstrap !== 'undefined') { + bootstrap.Modal.getOrCreateInstance(document.getElementById('modal-watch-add')).show(); + } +} + +async function saveWatchItem() { + const a = WATCH.pending || {}; + const wid = document.getElementById('wadd-list').value; + const msg = document.getElementById('wadd-msg'); + + if (!wid || wid === '0') { + msg.innerHTML = 'Créez d\'abord une liste ' + + '(bouton « Gérer les listes »).'; + return; + } + + const body = new URLSearchParams({ + action: 'watchlist_add', + watchlist_id: wid, + icao: document.getElementById('wadd-icao').value, + motif: document.getElementById('wadd-motif').value, + callsign: (a.callsign || '').trim(), + aircraft_type: a.aircraft_type || '', + aircraft_desc: a.aircraft_desc || '', + aircraft_wtc: a.aircraft_wtc || '', + operator_name: a.operator_name || '', + country: a.country || '', + }); + + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + await loadWatchIcaos(); + await loadWatchLists(); + refreshWatchButtons(); + bootstrap.Modal.getInstance(document.getElementById('modal-watch-add'))?.hide(); + if (document.getElementById('tab-surveillance')?.classList.contains('active')) { + loadWatchItems(); + } + } catch (e) { + msg.innerHTML = `${esc(e.message)}`; + } +} + +// ── Onglet Surveillance ────────────────────────────────────────────────────── +async function loadWatchItems() { + const tbody = document.getElementById('wat-tbody'); + const badge = document.getElementById('wat-count'); + if (!tbody) return; + + tbody.innerHTML = '' + + ' Chargement…'; + + const params = new URLSearchParams({ + action: 'watchlist_items', + watchlist_id: document.getElementById('wat-list').value, + from: depLocalToParam(document.getElementById('wat-from').value), + window: document.getElementById('wat-window').value, + }); + rxApply('wat', params); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const items = d.items || []; + WATCH.items = items; + badge.textContent = `${items.length} appareil(s)`; + + if (!items.length) { + tbody.innerHTML = '' + + 'Aucun appareil sous surveillance.
    ' + + 'Ajoutez-en depuis les onglets « Départs hors aérodromes » ' + + 'ou « Zones de départ » avec le bouton .' + + ''; + return; + } + + tbody.innerHTML = items.map((a, i) => { + const dep = Number(a.departs) || 0; + const depCls = dep >= 5 ? 'bg-danger' + : dep >= 2 ? 'bg-warning text-dark' + : dep >= 1 ? 'bg-info text-dark' : 'bg-dark border'; + // Un appareil sans contact sur la période mérite d'être signalé : la + // surveillance ne dit rien s'il n'a pas été entendu. + const last = a.last_ts_local + ? `${a.last_ts_local}` + : 'aucun contact'; + + return ` + ${esc(a.icao)} + ${esc((a.callsign || '').trim()) || ''} + ${esc(a.aircraft_desc || a.aircraft_type || '') || ''} + ${esc(a.aircraft_wtc || '—')} + ${esc(a.operator_name || '') || ''} + ${countryFlag(a.country)}${esc(a.country || '—')} + ${esc(a.liste_nom)} + + ${esc(a.motif || '') || '—'} + ${a.added_local || '—'} + ${dep} + ${last} + + + + + `; + }).join(''); + + } catch (e) { + console.error('watchlist_items:', e); + tbody.innerHTML = ` + Erreur : ${esc(e.message)}`; + } +} + +// Le point d'ancrage de la trajectoire est le dernier départ hors aérodrome +// détecté quand il y en a un — c'est ce qui a motivé la surveillance — et à +// défaut la dernière position connue. +function watchTrackAnchor(a) { + if (a.dep_ts_local) { + return { icao: a.icao, callsign: a.callsign, ts_local: a.dep_ts_local, + lat: a.dep_lat, lon: a.dep_lon, altitude: a.dep_alt }; + } + return { icao: a.icao, callsign: a.callsign, ts_local: a.last_ts_local, + lat: a.last_lat, lon: a.last_lon, altitude: a.last_alt }; +} + +// ── Gestion des listes (modale) ────────────────────────────────────────────── +function renderWatchLists() { + const tbody = document.getElementById('wlist-tbody'); + if (!tbody) return; + if (!WATCH.lists.length) { + tbody.innerHTML = '' + + 'Aucune liste. Créez-en une ci-dessus.'; + return; + } + tbody.innerHTML = WATCH.lists.map(l => ` + + ${esc(l.nom)} + ${esc(l.description || '—')} + ${l.appareils} + ${l.created_at} + + + + + `).join(''); +} + +async function saveWatchList() { + const msg = document.getElementById('wlform-msg'); + const nom = document.getElementById('wlform-nom').value.trim(); + if (!nom) { + msg.innerHTML = 'Le nom est obligatoire.'; + return; + } + try { + const r = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'watchlist_save', + id: document.getElementById('wlform-id').value, + nom, + couleur: document.getElementById('wlform-couleur').value, + description: document.getElementById('wlform-desc').value, + }), + }); + const d = await r.json(); + if (d.error) throw new Error(d.error); + msg.innerHTML = 'Enregistré.'; + resetWatchListForm(); + await loadWatchLists(); + renderWatchLists(); + setTimeout(() => { msg.innerHTML = ''; }, 2000); + } catch (e) { + msg.innerHTML = `${esc(e.message)}`; + } +} + +function resetWatchListForm() { + document.getElementById('wlform-id').value = '0'; + document.getElementById('wlform-nom').value = ''; + document.getElementById('wlform-desc').value = ''; + document.getElementById('wlform-couleur').value = '#d29922'; + document.getElementById('wlform-title').innerHTML = + ' Nouvelle liste'; + document.getElementById('wlform-cancel')?.classList.add('d-none'); +} + +// ── Câblage ────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + const wFrom = document.getElementById('wat-from'); + if (wFrom) wFrom.value = depDefaultFrom(43200); + + document.getElementById('wat-go')?.addEventListener('click', loadWatchItems); + document.getElementById('wat-list')?.addEventListener('change', loadWatchItems); + document.getElementById('wat-window')?.addEventListener('change', e => { + if (wFrom) wFrom.value = depDefaultFrom(parseInt(e.target.value, 10)); + loadReceivers('wat', true); + loadWatchItems(); + }); + document.getElementById('wat-from')?.addEventListener('change', () => { + loadReceivers('wat', true); + loadWatchItems(); + }); + + document.querySelector('a[href="#tab-surveillance"]') + ?.addEventListener('shown.bs.tab', async () => { + await loadWatchLists(); + await loadReceivers('wat', false); + loadWatchItems(); + }); + + // Actions du tableau de surveillance + document.getElementById('wat-tbody')?.addEventListener('click', async e => { + const t = e.target.closest('.wat-track-btn'); + if (t) { + const a = WATCH.items[Number(t.dataset.idx)]; + if (a) showTrack('wat', watchTrackAnchor(a)); + return; + } + const d = e.target.closest('.wat-del-btn'); + if (d) { + if (!confirm(`Retirer ${d.dataset.icao} de la liste « ${d.dataset.liste} » ?`)) return; + await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'watchlist_remove', icao: d.dataset.icao, + watchlist_id: d.dataset.wid, + }), + }); + await loadWatchIcaos(); + await loadWatchLists(); + refreshWatchButtons(); + loadWatchItems(); + } + }); + + document.getElementById('wadd-save')?.addEventListener('click', saveWatchItem); + + // Modale de gestion des listes + document.getElementById('modal-watchlists')?.addEventListener('show.bs.modal', async () => { + resetWatchListForm(); + await loadWatchLists(); + renderWatchLists(); + }); + document.getElementById('wlform-save')?.addEventListener('click', saveWatchList); + document.getElementById('wlform-cancel')?.addEventListener('click', resetWatchListForm); + + document.getElementById('wlist-tbody')?.addEventListener('click', async e => { + const ed = e.target.closest('.wl-edit'); + if (ed) { + const l = WATCH.lists.find(x => String(x.id) === ed.dataset.id); + if (!l) return; + document.getElementById('wlform-id').value = l.id; + document.getElementById('wlform-nom').value = l.nom; + document.getElementById('wlform-desc').value = l.description || ''; + document.getElementById('wlform-couleur').value = l.couleur || '#d29922'; + document.getElementById('wlform-title').innerHTML = + ' Modifier la liste'; + document.getElementById('wlform-cancel').classList.remove('d-none'); + return; + } + const del = e.target.closest('.wl-del'); + if (del) { + const n = Number(del.dataset.n) || 0; + const warn = n > 0 + ? `\n\nCette liste contient ${n} appareil(s) : ils seront également retirés.` + : ''; + if (!confirm(`Supprimer la liste « ${del.dataset.nom} » ?${warn}`)) return; + await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'watchlist_delete', id: del.dataset.id }), + }); + await loadWatchLists(); + await loadWatchIcaos(); + renderWatchLists(); + refreshWatchButtons(); + } + }); + + // Délégation posée une fois sur chaque tbody : les tableaux sont réécrits + // en entier à chaque recherche, des écouteurs par bouton seraient perdus. + const depTb = document.getElementById('dep-tbody'); + if (depTb) bindWatchButtons(depTb, () => DEP_STATE.rows || []); + const zdTb = document.getElementById('zd-tbody'); + if (zdTb) bindWatchButtons(zdTb, () => ZDETAIL.rows || []); + + // Ensemble des ICAO surveillés chargé au démarrage : les tableaux de départs + // doivent pouvoir afficher le bon état dès la première recherche. + loadWatchIcaos(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DÉCODAGE DES CODES AÉRONAUTIQUES +// +// Type OACI (aircraft_desc) : 3 caractères — [catégorie][nb moteurs][motorisation] +// ex. L2J = avion terrestre (Landplane), 2 moteurs, réacteurs (Jet) +// +// Catégorie de turbulence de sillage (WTC) : masse au décollage, qui détermine +// les séparations minimales imposées par le contrôle aérien. +// ═══════════════════════════════════════════════════════════════════════════════ + +const AC_CATEGORY = { + L: 'Avion terrestre (Landplane)', + S: 'Hydravion (Seaplane)', + A: 'Amphibie (Amphibian)', + H: 'Hélicoptère', + G: 'Autogire (Gyrocopter)', + T: 'Convertible (Tiltrotor)', +}; + +const AC_ENGINE = { + P: 'moteurs à pistons', + T: 'turbopropulseurs', + J: 'réacteurs', + E: 'moteurs électriques', + R: 'moteur-fusée', +}; + +const WTC_INFO = { + L: { nom: 'Light — léger', + txt: 'Masse maximale au décollage ≤ 7 tonnes.
    ' + + 'Aviation générale, petits bimoteurs, hélicoptères légers.' }, + M: { nom: 'Medium — moyen', + txt: 'Masse maximale au décollage entre 7 et 136 tonnes.
    ' + + 'Jets d\u2019affaires, A320, B737, ATR…' }, + H: { nom: 'Heavy — lourd', + txt: 'Masse maximale au décollage supérieure à 136 tonnes.
    ' + + 'A330, A350, B777, B747…' }, + J: { nom: 'Super (J) — A380', + txt: 'Catégorie spécifique à l\u2019Airbus A380.
    ' + + 'Impose les séparations les plus larges.' }, +}; + +// Décrit un code type OACI en clair +function decodeAcType(code) { + if (!code || code.length < 3) return null; + const c = code.toUpperCase(); + const cat = AC_CATEGORY[c[0]]; + const nb = parseInt(c[1], 10); + const eng = AC_ENGINE[c[2]]; + if (!cat || isNaN(nb) || !eng) return null; + // Accord au singulier : « 1 réacteur » et non « 1 réacteurs » + const lbl = nb === 1 ? eng.replace(/s$/, '').replace('moteurs', 'moteur') : eng; + return `${c}
    ${cat}
    ${nb} ${lbl}`; +} + +function decodeWtc(code) { + if (!code) return null; + const w = WTC_INFO[code.toUpperCase()]; + return w ? `${w.nom}
    ${w.txt}` : null; +} + +// ── Drapeaux ───────────────────────────────────────────────────────────────── +// Emoji plutôt qu'images : aucune dépendance réseau, aucun fichier à héberger, +// et le rendu suit la police système. Les pays viennent de la base tar1090, +// en anglais. +const COUNTRY_ISO = { + 'France':'FR','Germany':'DE','Spain':'ES','Italy':'IT','Portugal':'PT', + 'Belgium':'BE','Netherlands':'NL','Luxembourg':'LU','Switzerland':'CH', + 'Austria':'AT','United Kingdom':'GB','Ireland':'IE','Denmark':'DK', + 'Norway':'NO','Sweden':'SE','Finland':'FI','Iceland':'IS','Poland':'PL', + 'Czech Republic':'CZ','Czechia':'CZ','Slovakia':'SK','Hungary':'HU', + 'Romania':'RO','Bulgaria':'BG','Greece':'GR','Croatia':'HR','Slovenia':'SI', + 'Serbia':'RS','Bosnia and Herzegovina':'BA','Albania':'AL','Malta':'MT', + 'Cyprus':'CY','Estonia':'EE','Latvia':'LV','Lithuania':'LT','Ukraine':'UA', + 'Belarus':'BY','Russia':'RU','Russian Federation':'RU','Turkey':'TR', + 'United States':'US','USA':'US','Canada':'CA','Mexico':'MX','Brazil':'BR', + 'Argentina':'AR','Chile':'CL','Colombia':'CO','Peru':'PE','Venezuela':'VE', + 'China':'CN','Japan':'JP','South Korea':'KR','Korea':'KR','India':'IN', + 'Pakistan':'PK','Thailand':'TH','Vietnam':'VN','Singapore':'SG', + 'Malaysia':'MY','Indonesia':'ID','Philippines':'PH','Australia':'AU', + 'New Zealand':'NZ','Israel':'IL','Saudi Arabia':'SA','Qatar':'QA', + 'United Arab Emirates':'AE','UAE':'AE','Kuwait':'KW','Bahrain':'BH', + 'Oman':'OM','Jordan':'JO','Lebanon':'LB','Egypt':'EG','Morocco':'MA', + 'Algeria':'DZ','Tunisia':'TN','Libya':'LY','South Africa':'ZA', + 'Nigeria':'NG','Kenya':'KE','Ethiopia':'ET','Ghana':'GH','Senegal':'SN', + 'Ivory Coast':'CI','Cote d\u2019Ivoire':'CI','Cameroon':'CM','Gabon':'GA', + 'Monaco':'MC','San Marino':'SM','Andorra':'AD','Liechtenstein':'LI', + 'Isle of Man':'IM','Guernsey':'GG','Jersey':'JE','Bermuda':'BM', + 'Cayman Islands':'KY','Aruba':'AW','Bahamas':'BS','Panama':'PA', + 'Costa Rica':'CR','Dominican Republic':'DO','Cuba':'CU','Jamaica':'JM', + 'Kazakhstan':'KZ','Uzbekistan':'UZ','Azerbaijan':'AZ','Georgia':'GE', + 'Armenia':'AM','Moldova':'MD','Montenegro':'ME','North Macedonia':'MK', + 'Iran':'IR','Iraq':'IQ','Syria':'SY','Afghanistan':'AF','Bangladesh':'BD', + 'Sri Lanka':'LK','Nepal':'NP','Myanmar':'MM','Cambodia':'KH','Laos':'LA', + 'Mongolia':'MN','Taiwan':'TW','Hong Kong':'HK','Macau':'MO', +}; + +// ── Rendu des drapeaux ─────────────────────────────────────────────────────── +// Images et non emoji : les emoji drapeaux (symboles indicateurs régionaux) ne +// sont PAS rendus sous Windows — Chrome et Edge affichent le code à deux +// lettres à la place. Seuls macOS, iOS, Android et la plupart des Linux les +// affichent correctement. +// +// Source : flagcdn.com, domaine public, libre d'usage sans attribution ni +// limite de requêtes. Si tu préfères tout héberger localement, dépose des PNG +// dans webapp/src/assets/flags/.png et passe FLAG_LOCAL à true : le code +// bascule alors sans autre modification. +const FLAG_LOCAL = true; +const FLAG_BASE = FLAG_LOCAL ? '/assets/flags/' : 'https://flagcdn.com/24x18/'; +const FLAG_EXT = FLAG_LOCAL ? '.png' : '.png'; + +// Pictogramme « pays inconnu » : SVG en data-URI, donc toujours disponible, +// même sans réseau et sans fichier à déployer. +const FLAG_UNKNOWN = + 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + '?'); + +function flagImg(iso, titre) { + const t = (titre || 'Pays inconnu').replace(/"/g, '"'); + if (!iso) { + return `?`; + } + // onerror : si le CDN est injoignable ou le code inconnu, on retombe sur le + // pictogramme local plutôt que d'afficher une image cassée. + return `${iso}`; +} + +function countryFlag(nom) { + if (!nom || nom === 'unknown' || nom === 'Unknown') { + return flagImg(null, 'Pays inconnu'); + } + const iso = COUNTRY_ISO[nom]; + return flagImg(iso || null, iso ? nom : `${nom} (code pays non répertorié)`); +} + +// ── Infobulles Bootstrap sur les codes ─────────────────────────────────────── +// Réinitialisées après chaque rendu du tableau : les éléments sont recréés. +function zdInitTooltips(root) { + if (typeof bootstrap === 'undefined') return; + root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { + bootstrap.Tooltip.getInstance(el)?.dispose(); + new bootstrap.Tooltip(el, { html: true, container: 'body', placement: 'top' }); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TRAJECTOIRE D'UN AÉRONEF — carte + profil d'altitude +// +// Au clic sur une ligne de la liste des départs : trace le chemin suivi autour +// du point détecté et l'évolution de l'altitude. Permet de trancher entre un +// vrai décollage local (départ du sol, montée continue) et un appareil déjà en +// vol capté tardivement (altitude non nulle dès le premier point, trajectoire +// rectiligne). +// ═══════════════════════════════════════════════════════════════════════════════ + +// Deux instances : l'onglet liste et l'onglet zones ont chacun leur panneau, +// leur carte Leaflet et leur graphique. Une seule instance partagée obligerait +// à déplacer le DOM d'un onglet à l'autre, ce que Leaflet supporte mal. +const TRACK = { map: null, layer: null, chart: null, current: null }; +const ZTRACK = { map: null, layer: null, chart: null, current: null }; +const WTRACK = { map: null, layer: null, chart: null, current: null }; + +const TRACK_UI = { + dep: { st: TRACK, panel: 'dep-track', mapEl: 'map-track', chartEl: 'chart-alt', + title: 'dt-title', sub: 'dt-sub', points: 'dt-points', + route: 'dt-route', win: 'dt-window' }, + zon: { st: ZTRACK, panel: 'zon-track', mapEl: 'map-ztrack', chartEl: 'chart-zalt', + title: 'zt-title', sub: 'zt-sub', points: 'zt-points', + route: 'zt-route', win: 'zt-window' }, + wat: { st: WTRACK, panel: 'wat-track', mapEl: 'map-wtrack', chartEl: 'chart-walt', + title: 'wt-title', sub: 'wt-sub', points: 'wt-points', + route: 'wt-route', win: 'wt-window' }, +}; + +function trackClose(which) { + const U = TRACK_UI[which]; + document.getElementById(U.panel)?.classList.add('d-none'); + U.st.current = null; +} + +function dtClose() { trackClose('dep'); } +function ztClose() { trackClose('zon'); } + +function initTrackMap(which) { + const U = TRACK_UI[which]; + if (U.st.map) return; + U.st.map = L.map(U.mapEl, + { center: [48.8236, 2.2770], zoom: 10, zoomControl: true, scrollWheelZoom: true }); + addFitControl(U.st.map, 'Revoir la trajectoire entière'); + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OSM © CARTO', subdomains: 'abcd', maxZoom: 19, + }).addTo(U.st.map); + U.st.layer = L.layerGroup().addTo(U.st.map); +} + +// Couleur selon l'altitude, cohérente avec la légende des autres cartes +function altColor(ft) { + if (ft == null) return '#8b949e'; + if (ft < 3000) return '#3fb950'; + if (ft < 8000) return '#58a6ff'; + if (ft < 15000) return '#a371f7'; + if (ft < 25000) return '#d29922'; + if (ft < 35000) return '#db6d28'; + return '#f85149'; +} + +// Wrapper conservé : le tableau de l'onglet liste appelle showAircraftTrack(i) +function showAircraftTrack(idx) { + const a = DEP_STATE.rows?.[idx]; + if (a) showTrack('dep', a); +} + +// Cœur commun aux deux onglets. `a` doit porter icao, ts_local, lat, lon, +// altitude — c'est le cas des lignes renvoyées par departures_offfield comme +// par departures_offfield_zone_detail. +async function showTrack(which, a) { + if (!a) return; + const U = TRACK_UI[which], T = U.st; + + const panel = document.getElementById(U.panel); + if (!panel) return; + panel.classList.remove('d-none'); + initTrackMap(which); + + document.getElementById(U.title).textContent = + `${a.icao}${a.callsign ? ' — ' + a.callsign : ''}`; + document.getElementById(U.sub).textContent = a.ts_local || ''; + document.getElementById(U.points).textContent = 'chargement…'; + document.getElementById(U.route).innerHTML = ''; + + T.current = a; + if (which !== 'zon') panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + const win = parseInt(document.getElementById(U.win).value, 10) || 30; + const params = new URLSearchParams({ + action: 'aircraft_track', + icao: a.icao, + ts: a.ts_local ? a.ts_local.replace( + /^(\d{2})\/(\d{2})\/(\d{4}) /, '$3-$2-$1 ') : '', + before: Math.round(win / 2), + after: win, + }); + + try { + const r = await fetch('api.php?' + params); + const d = await r.json(); + if (d.error) throw new Error(d.error); + + const pts = (d.track || []).filter(p => p.lat != null && p.lon != null); + document.getElementById(U.points).textContent = `${pts.length} point(s)`; + + if (d.info && (d.info.origin || d.info.destination)) { + document.getElementById(U.route).innerHTML = + ` ` + + `${d.info.origin_name || d.info.origin || '?'} → ` + + `${d.info.destination_name || d.info.destination || '?'}`; + } + + // ── Carte ──────────────────────────────────────────────────────────────── + T.layer.clearLayers(); + if (!pts.length) { + T.map.setView([a.lat, a.lon], 11); + } else { + // Segments colorés par altitude : la montée se lit d'un coup d'œil + for (let i = 1; i < pts.length; i++) { + L.polyline( + [[pts[i-1].lat, pts[i-1].lon], [pts[i].lat, pts[i].lon]], + { color: altColor(pts[i].altitude), weight: 3, opacity: .9 } + ).addTo(T.layer); + } + + // Point détecté comme départ + L.circleMarker([a.lat, a.lon], { + radius: 7, color: '#f85149', weight: 3, + fillColor: '#f85149', fillOpacity: .5, + }).bindTooltip( + `Point détecté
    ${a.ts_local}
    ` + + `${Number(a.altitude).toLocaleString('fr-FR')} ft`, { permanent: false } + ).addTo(T.layer); + + // Début et fin de trace + const p0 = pts[0], pN = pts[pts.length - 1]; + L.circleMarker([p0.lat, p0.lon], { + radius: 5, color: '#3fb950', fillColor: '#3fb950', fillOpacity: .8, weight: 2, + }).bindTooltip(`Début — ${p0.hhmm} — ${p0.altitude ?? '?'} ft`).addTo(T.layer); + L.circleMarker([pN.lat, pN.lon], { + radius: 5, color: '#a371f7', fillColor: '#a371f7', fillOpacity: .8, weight: 2, + }).bindTooltip(`Fin — ${pN.hhmm} — ${pN.altitude ?? '?'} ft`).addTo(T.layer); + + T.map.fitBounds( + L.latLngBounds(pts.map(p => [p.lat, p.lon])), { padding: [30, 30] }); + } + setTimeout(() => T.map.invalidateSize(), 60); + + // ── Profil d'altitude ──────────────────────────────────────────────────── + const ctx = document.getElementById(U.chartEl); + T.chart?.destroy(); + + if (pts.length) { + // Index du point détecté, pour le marquer sur la courbe + let refIdx = 0, best = Infinity; + pts.forEach((p, i) => { + const dd = Math.abs(Number(p.dt_sec)); + if (dd < best) { best = dd; refIdx = i; } + }); + + T.chart = new Chart(ctx, { + type: 'line', + data: { + labels: pts.map(p => p.hhmm), + datasets: [{ + label: 'Altitude (ft)', + data: pts.map(p => p.altitude), + borderColor: '#58a6ff', + backgroundColor: 'rgba(88,166,255,.12)', + borderWidth: 2, fill: true, tension: .25, + pointRadius: pts.map((p, i) => i === refIdx ? 6 : 0), + pointBackgroundColor: '#f85149', + pointBorderColor: '#f85149', + }, { + label: 'Vitesse sol (kt)', + data: pts.map(p => p.speed), + borderColor: '#d29922', borderWidth: 1.5, + borderDash: [4, 3], fill: false, tension: .25, + pointRadius: 0, yAxisID: 'y2', + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { labels: { color: '#8b949e', boxWidth: 12, font: { size: 10 } } }, + tooltip: { + callbacks: { + afterBody: items => { + const p = pts[items[0].dataIndex]; + return [`Montée : ${p.vrate != null + ? (p.vrate > 0 ? '+' : '') + p.vrate + ' ft/min' : '—'}`, + `Cap : ${p.track != null ? Math.round(p.track) + '°' : '—'}`]; + }, + }, + }, + }, + scales: { + x: { ticks: { color: '#6e7681', maxTicksLimit: 8, font: { size: 9 } }, + grid: { color: '#21262d' } }, + y: { title: { display: true, text: 'Altitude (ft)', color: '#8b949e', + font: { size: 10 } }, + ticks: { color: '#6e7681', font: { size: 9 } }, + grid: { color: '#21262d' }, beginAtZero: true }, + y2: { position: 'right', title: { display: true, text: 'kt', + color: '#8b949e', font: { size: 10 } }, + ticks: { color: '#6e7681', font: { size: 9 } }, + grid: { display: false }, beginAtZero: true }, + }, + }, + }); + } + + } catch (e) { + console.error('track:', e); + document.getElementById(U.points).textContent = 'erreur : ' + e.message; + } +} + +// ── Câblage ────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('dt-close')?.addEventListener('click', dtClose); + document.getElementById('dt-window')?.addEventListener('change', () => { + if (TRACK.current) showTrack('dep', TRACK.current); + }); + + document.getElementById('zt-close')?.addEventListener('click', ztClose); + document.getElementById('zt-window')?.addEventListener('change', () => { + if (ZTRACK.current) showTrack('zon', ZTRACK.current); + }); + + document.getElementById('wt-close') + ?.addEventListener('click', () => trackClose('wat')); + document.getElementById('wt-window')?.addEventListener('change', () => { + if (WTRACK.current) showTrack('wat', WTRACK.current); + }); +}); diff --git a/webapp/src/status.html b/webapp/src/status.html new file mode 100644 index 0000000..ee27d41 --- /dev/null +++ b/webapp/src/status.html @@ -0,0 +1,124 @@ + + + + + + +ADS-B Linux-25 — Status + + + +
    +
    +

    ✈ ADS-B Linux-25 — Status

    +
    Généré le lun. 10/08/2026 02:20:01 — actualisation auto toutes les 60s
    +
    ✗ 1 erreur(s)
    +
    +
    +
    IP : 192.168.2.69
    +
    + Erreurs : 1   + Avert. : 8 +
    +
    +
    + +
    +
    +
    2
    Avions en vue
    +
    +
    2
    Avec GPS
    +
    +
    0
    +
    Avions aujourd'hui
    +
    +
    0
    Sessions aujourd'hui
    +
    +
    2583 MB
    +
    Base adsb
    +
    +
    49.6 dB
    +
    Gain SDR
    +
    + + + + + + + + +
    ÉtatDétailStatut
    IP : 192.168.2.69 — lun. 10/08/2026 02:20:01
    ▸ 1. Conteneurs Docker
    1 dongle(s) détecté(s) : 00000011 — slots rx1..rx1 attendus
    adsb-readsb [healthy] — démarré 08/08 18:20OK
    adsb-tar1090 [healthy] — démarré 09/08 03:00OK
    adsb-adsb2pg — STATUS=restartingERREUR
    adsb-webapp — démarré 08/08 09:55OK
    wordpress-postgres — démarré 08/08 09:55OK
    ▸ 2. Dongle(s) RTL-SDR
    adsb-readsb — SN:00000011 présent sur le bus USB — gain 49.6 dBOK
    Symlink /dev/adsb_dongle absentATTENTION
    Symlink → /dev/adsb_dongle ()
    Tous les numéros de série RTL-SDR détectés sont distincts (1 récepteur(s))OK
    ▸ 3. Données ADS-B
    adsb-readsb (:8090) actif — 5,996,448 messages décodésOK
    adsb-readsb — 2 avion(s) en vue dont 2 avec position GPSOK
    adsb-readsb — compteur messages en progression (5994262 → 5996448 depuis 299s)OK
    Volume readsb-run : aircraft.pb introuvable (mode host normal)ATTENTION
    ▸ 4. Ports réseau
    Port 8080 en écoute sur *:8080 — webapp PHP (adsb-webapp)OK
    Port 5432 en écoute sur 127.0.0.1:5432 — PostgreSQL (wordpress-postgres)OK
    Port 8090 en écoute sur 0.0.0.0:8090 — tar1090 nginx (adsb-tar1090)OK
    Port 30005 en écoute sur 0.0.0.0:30005 — Beast TCP (adsb-readsb)OK
    Port 30002 en écoute sur 127.0.0.1:30002 — RAW TCP (adsb-readsb)OK
    Port 30003 en écoute sur 127.0.0.1:30003 — SBS TCP (adsb-readsb)OK
    adsb-readsb : adsb-net:172.20.0.3OK
    adsb-tar1090 : adsb-net:172.20.0.2
    adsb-adsb2pg : mode=host
    adsb-webapp : mode=host
    Connexion Beast tar1090→readsb OKOK
    ▸ 4b. Connectivité réseau inter-conteneurs
    HTTP tar1090 (127.0.0.1:8090) : HTTP 200OK
    aircraft.json tar1090 (127.0.0.1:8090) : HTTP 200OK
    API readsb port 30053 : non exposé (normal en mode /data/adsb)
    Beast TCP hôte→readsb (127.0.0.1:30005) : connexion OKOK
    RAW TCP hôte→readsb (127.0.0.1:30002) : connexion OKOK
    SBS TCP hôte→readsb (127.0.0.1:30003) : connexion OKOK
    PostgreSQL hôte→wordpress-postgres (127.0.0.1:5432) : connexion OKOK
    adsb2pg DUMP1090_URL (http://127.0.0.1:8090/data/aircraft.json) : HTTP 200OK
    Webapp LAN (http://192.168.2.69:8080) : HTTP 200OK
    tar1090 LAN (http://192.168.2.69:8090) : HTTP 200OK
    bridge-nf-call-iptables=0 (trafic inter-conteneurs non filtré)OK
    adsb-net membres : adsb-tar1090=172.20.0.2/16 | adsb-readsb=172.20.0.3/16
    ▸ 5. PostgreSQL — schéma adsb
    PostgreSQL accessible — PostgreSQL 16.9 (Debian 16.9-1.pgdg110+1) on x86_64-pc-OK
    132 partitions adsb.positions — nombre élevé, vérifier qu'il n'y a pas de sous-partitions HASH résiduellesATTENTION
    Aucune sous-partition HASH résiduelle sur adsb.positionsOK
    Partition semaine courante présente : positions_2026_w33 (RANGE simple)OK
    Partition semaine suivante déjà créée : positions_2026_w34OK
    Continuité des partitions positions vérifiée (aucun trou entre semaines)OK
    32 sous-partitions adsb.aircraft_historyOK
    Récepteur 00000011 enregistré et actif — "AirNav FlightStick #1"OK
    Aucune position dans les 5 dernières minutesATTENTION
    aircraft_desc rempli à 0% (< 80%)ATTENTION
    Aujourd'hui : 0 avions distincts, 0 sessions
    Taille totale schéma adsb : 2583 MB
    Connexions PostgreSQL actives : 1
    ▸ 5b. Volumes PostgreSQL (conf & logs)
    Volume Docker 'pg-conf-std' existe — Mountpoint : /var/snap/docker/common/var-lib-docker/volumes/pg-conf-std/_dataOK
    Volume Docker 'pg-logs-std' existe — Mountpoint : /var/snap/docker/common/var-lib-docker/volumes/pg-logs-std/_dataOK
    Lien symbolique /data/adsb/postgres/conf → /var/snap/docker/common/var-lib-docker/volumes/pg-conf-std/_dataOK
    Lien symbolique /data/adsb/postgres/logs → /var/snap/docker/common/var-lib-docker/volumes/pg-logs-std/_dataOK
    postgresql.conf présent (31117 octets, modifié il y a 3488061s, droits 644 dnsmasq:systemd-journal)OK
    postgresql.conf lisible depuis l'hôteOK
    max_locks_per_transaction = 2048 (fichier conf)
    log_directory = /var/log/postgresql
    logging_collector = onOK
    Répertoire conf : /var/snap/docker/common/var-lib-docker/volumes/pg-conf-std/_data — droits dnsmasq:systemd-journal (755)
    Répertoire logs : propriétaire uid=999 (postgres Debian) — dnsmasq:systemd-journal (750)OK
    Répertoire logs lisible depuis l'hôte (root)OK
    27 fichier(s) de log PostgreSQL présent(s) dans le volumeOK
    Log du jour actif : postgresql-2026-08-10.log (202568 octets, mis à jour il y a 0s)OK
    Dernière ligne log : 2026-08-10 00:20:08 UTC [111342] pgz_admin@wpz_postgres LOG: disconnection: session time: 0:00:00.007 user=pgz_admin da
    27 fichier(s) CSV de log présent(s)
    Accès direct aux logs : tail -f /var/snap/docker/common/var-lib-docker/volumes/pg-logs-std/_data/postgresql-$(date +%Y-%m-%d).log
    Volumes montés dans wordpress-postgres : pg-conf-std | postgres_data | pg-logs-std
    Volume 'pg-conf-std' monté dans wordpress-postgresOK
    Volume 'pg-logs-std' monté dans wordpress-postgresOK
    PostgreSQL config_file actif : /etc/postgresql/postgresql.confOK
    PostgreSQL log_directory : /var/log/postgresql
    max_locks_per_transaction (runtime) : 2048OK
    logging_collector = on (runtime)OK
    ▸ 5c. PostgreSQL — extensions et paramètres critiques
    Extension postgis installée — version 3.5.2OK
    Colonne geom présente sur adsb.positions (GENERATED ALWAYS, héritage partitions)OK
    Index GiST sur geom présent sur la partition active (positions_2026_w33) — requêtes bbox optimiséesOK
    Partition active : positions_2026_w33
    Volume pg-conf-std : propriétaire dnsmasq:systemd-journal (755) — OK pour image Debian (uid=999)OK
    Volume pg-logs-std : propriétaire dnsmasq:systemd-journal (750) — OK pour image Debian (uid=999)OK
    Volume postgres_data : propriétaire dnsmasq:systemd-journal (700) — OK pour image Debian (uid=999)OK
    postgresql.auto.conf accessible (/var/snap/docker/common/var-lib-docker/volumes/postgres_data/_data/postgresql.auto.conf)OK
    Contenu (paramètres ALTER SYSTEM, prioritaires sur postgresql.conf) :
    auto.conf → max_connections = '200'
    auto.conf → logging_collector = 'on'
    auto.conf → max_locks_per_transaction = '2048'
    auto.conf → shared_buffers = '3200MB'
    work_mem dans les limites acceptables (?)OK
    random_page_cost (runtime) : 1.1 (attendu 1.1 pour SSD)
    effective_io_concurrency (runtime) : 150 (attendu 150 pour SSD)
    ▸ 6. adsb2pg — collecteur(s)
    adsb-adsb2pg — aucun log d'insertion trouvéATTENTION
    adsb-adsb2pg — dernier warning/erreur : SyntaxError: source code cannot contain null bytes
    ▸ 6b. Watchdog readsb — résilience
    Script watchdog présent et exécutable (/data/adsb/scripts/watchdog_readsb.sh)OK
    Watchdog actif dans la crontab — */5 * * * * /data/adsb/scripts/watchdog_readsb.shOK
    Watchdog actif pour adsb-readsb — dernière exécution il y a 10s (< 10 min)OK
    adsb-readsb — aucun redémarrage watchdog déclenché sur les dernières 24hOK
    Dernière ligne log watchdog : 355) - compteur de blocage remis à zéro
    adsb-readsb : RestartCount=0 (aucun crash Docker)OK
    adsb-tar1090 : RestartCount=0 (aucun crash Docker)OK
    adsb-adsb2pg : RestartCount=1396 — le conteneur a redémarré (crash ou OOM), vérifier docker logs adsb-adsb2pgATTENTION
    adsb-readsb protégé par le watchdog (v4 — récepteurs dérivés de detect_dongles.sh)OK
    Horloge système synchronisée NTPOK
    ▸ 7. Webapp — interface utilisateur
    api.php?action=live : JSON valideOK
    api.php?action=kpi : JSON valideOK
    api.php?action=trajectories : JSON valideOK
    9 onglets dans index.phpOK
    app.js : 197906 octets
    Accès LAN http://192.168.2.69:8080 : HTTP 200OK
    ▸ 8. Autogain
    adsb-readsb — Gain : 49.6 dB — état autogain : inconnuOK
    adsb-readsb — 0 cycle(s) avec messages insuffisants (autogain en cours)
    ▸ 9. Infrastructure hôte
    Docker 29.6.1 (snap rev.3579)
    bridge-nf-call-iptables=0 — ICC Docker OKOK
    ip_forward=1 — routage inter-réseaux actifOK
    Disque /data : 15% utilisé (14G/98G)
    Disque /opt : 1% utilisé (15M/1,8G)
    Disque /var/snap/docker : 81% utilisé (8,4G/11G)
    Système : up 1 day, 16 hours, 25 minutes
    4 événement(s) reset/déconnexion sur le port du dongle RTL-SDR (1-3) — corrélation possible avec les blocages watchdogATTENTION
    Dongle RTL-SDR localisé : port 1-3 (bus 001 device 010)
    Règle udev 99-adsb-docker.rules présente (relance auto si USB reconnecté)OK
    adsb-stack.service : active (WorkingDirectory=/data/adsb)OK
    ▸ 9b. Pare-feu
    ufw inactif — pas de restriction sur les ports du stackOK
    Webapp accessible en local (127.0.0.1:8080) : HTTP 200OK
    Webapp accessible depuis l'IP LAN (192.168.2.69:8080) : HTTP 200OK
    + + + + + diff --git a/webapp/src/watchlist_schema.sql b/webapp/src/watchlist_schema.sql new file mode 100644 index 0000000..054f387 --- /dev/null +++ b/webapp/src/watchlist_schema.sql @@ -0,0 +1,71 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- Listes de surveillance d'aéronefs +-- +-- Deux tables plutôt qu'une colonne « liste » libre : une liste est un objet +-- qu'on renomme, colore et supprime, et sa suppression doit emporter ses +-- membres. Une chaîne répétée sur chaque ligne rendrait le renommage fragile. +-- +-- La clé de surveillance est l'adresse ICAO 24 bits, stable pour une cellule +-- donnée, et non l'indicatif, qui change à chaque vol et manque souvent. +-- +-- À jouer avec ON_ERROR_STOP=1. +-- ═══════════════════════════════════════════════════════════════════════════ + +BEGIN; + +CREATE TABLE IF NOT EXISTS adsb.watchlists ( + id serial PRIMARY KEY, + nom varchar(60) NOT NULL UNIQUE, + couleur varchar(9) NOT NULL DEFAULT '#d29922', + description text, + actif boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE adsb.watchlists IS + 'Listes de surveillance nommées (ex. Militaires, Hélicoptères).'; + +CREATE TABLE IF NOT EXISTS adsb.watchlist_items ( + id serial PRIMARY KEY, + watchlist_id integer NOT NULL + REFERENCES adsb.watchlists(id) ON DELETE CASCADE, + icao varchar(6) NOT NULL, + motif text, + -- Instantané des métadonnées au moment de la mise sous surveillance. + -- Redondant avec aircraft_history, et c'est voulu : il garde une trace de + -- ce qui avait motivé l'ajout, même si la fiche de l'appareil change + -- ensuite, et évite une jointure à chaque affichage de la liste. + callsign varchar(12), + aircraft_type varchar(8), + aircraft_desc varchar(60), + aircraft_wtc varchar(4), + operator_name varchar(120), + country varchar(60), + added_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT watchlist_items_uniq UNIQUE (watchlist_id, icao), + -- Normalisation imposée en base : l'API majuscule déjà, mais une insertion + -- manuelle en minuscules créerait un doublon invisible. + CONSTRAINT watchlist_items_icao_upper CHECK (icao = upper(icao)) +); + +-- Recherche « cet appareil est-il surveillé ? » lors du rendu des tableaux +CREATE INDEX IF NOT EXISTS watchlist_items_icao_idx + ON adsb.watchlist_items (icao); + +COMMENT ON TABLE adsb.watchlist_items IS + 'Aéronefs surveillés, identifiés par leur adresse ICAO 24 bits.'; + +-- Liste par défaut, pour que le bouton « Surveiller » ait une cible dès le +-- premier clic sans imposer la création préalable d'une liste. +INSERT INTO adsb.watchlists (nom, couleur, description) +SELECT 'Surveillance', '#d29922', 'Liste par défaut' +WHERE NOT EXISTS (SELECT 1 FROM adsb.watchlists); + +COMMIT; + +\echo '--- Tables créées ---' +SELECT w.id, w.nom, w.couleur, w.actif, count(i.id) AS appareils +FROM adsb.watchlists w +LEFT JOIN adsb.watchlist_items i ON i.watchlist_id = w.id +GROUP BY w.id, w.nom, w.couleur, w.actif +ORDER BY w.id; diff --git a/zones_gouvernementales.sql b/zones_gouvernementales.sql new file mode 100644 index 0000000..d3adf93 --- /dev/null +++ b/zones_gouvernementales.sql @@ -0,0 +1,51 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- Zones d'exclusion : Élysée, Bercy, et libellé de la BA 107 +-- +-- geom est GENERATED ALWAYS ... STORED : ne jamais la citer dans un INSERT, +-- PostgreSQL la calcule depuis lon/lat. +-- +-- source = 'manuelle' et non 'reference' : les fiches de référence ont leurs +-- nom et coordonnées verrouillés dans airport_save, ce qui empêcherait de +-- corriger la position depuis la carte. Or ces deux hélisurfaces sont +-- positionnées à l'estime et demanderont un ajustement. +-- +-- À jouer avec ON_ERROR_STOP=1. +-- ═══════════════════════════════════════════════════════════════════════════ + +BEGIN; + +-- ── 1. Hélisurfaces gouvernementales ──────────────────────────────────────── +-- Rayon 300 m : ces sites sont ponctuels et en tissu urbain dense. Un rayon +-- kilométrique effacerait des départs légitimes du centre de Paris. +INSERT INTO adsb.airports (code, nom, lat, lon, rayon_km, type, actif, + commentaire, source) +SELECT 'Z001', 'Palais de l''Élysée', 48.870400, 2.316600, 0.3, 'heliport', true, + 'Hélisurface de la présidence de la République — position à affiner sur la carte', + 'manuelle' +WHERE NOT EXISTS (SELECT 1 FROM adsb.airports WHERE nom ILIKE '%Élysée%'); + +INSERT INTO adsb.airports (code, nom, lat, lon, rayon_km, type, actif, + commentaire, source) +SELECT 'Z002', 'Bercy — Ministère de l''Économie', 48.838800, 2.377100, 0.3, + 'heliport', true, + 'Hélistation sur ponton Seine — position à affiner sur la carte', + 'manuelle' +WHERE NOT EXISTS (SELECT 1 FROM adsb.airports WHERE nom ILIKE '%Bercy%'); + +-- ── 2. BA 107 : la zone existe déjà sous LFPV, on complète le libellé ────── +-- Pas de nouvelle fiche : deux cercles concentriques ne changeraient rien à la +-- détection (NOT EXISTS sur l'ensemble des zones actives) et compliqueraient +-- la maintenance. +UPDATE adsb.airports + SET nom = 'Vélizy-Villacoublay — Base aérienne 107', + commentaire = COALESCE(NULLIF(commentaire, ''), + 'BA 107 « Sous-lieutenant René Dorme » — ' + || 'transport gouvernemental et ETEC') + WHERE code = 'LFPV'; + +COMMIT; + +\echo '--- Zones d''exclusion après migration ---' +SELECT id, code, nom, lat, lon, rayon_km, type, actif, source + FROM adsb.airports + ORDER BY rayon_km, code;