Skip to content

Repository files navigation

Air Alerts Stats

Статистика повітряних тривог за обрану добу для будь-якої області, району чи громади України: скільки тривог, скільки сумарно часу, коли саме, і як доба виглядає на тлі сусідніх.

Самохостинг: FastAPI + SQLite + ванільний фронтенд, два контейнери, без зовнішніх залежностей крім самого API тривог. Розрахований на те, щоб швидко вантажитись на телефоні через VPN.

Автентифікації в застосунку немає. Він задуманий як особистий сервіс за VPN або на localhost. Типовий BIND_ADDR=127.0.0.1 навмисне не виставляє його в мережу — не міняй на 0.0.0.0, не поставивши перед ним щось, що питає пароль.


Чому не «просто запит до API»

Жодне публічне джерело не віддасть «скільки тривог було 20 серпня» через півроку. alerts.in.ua має історичний ендпоінт GET /v1/regions/{uid}/alerts/{period}.json, але:

Обмеження Наслідок
єдиний дозволений periodmonth_ago глибше ніж на місяць назад API не дивиться
ліміт на цей ендпоінт — 2 запити/хв обійти 27 областей = ~15 хв на один прохід
загальний ліміт — 8–10 req/min (soft), 12 (hard) live-опитування частіше ніж раз на ~6 с — 429
потрібен токен за заявкою і його можуть не дати

Тому історію веде власна БД, а джерело даних — змінна величина. Від джерела потрібне рівно одне: «хто зараз під тривогою». Це дають і безключові агрегатори.

Джерела (ALERTS_PROVIDER у .env)

Значення Ключ Гранулярність Історія Рівень Примітка
alerts_in_ua (діє з 05.09.2026) треба області → райони → громади → міста так, місяць+ так підтверджені API часи завершення
siren не треба області → райони → громади (1606 регіонів) ні так дзеркало офіційного ukrainealarm v3 (ДСНС); сторонній проксі
ubilling не треба лише області (25) ні ні ubilling.net.ua/aerialalerts, віддає точний момент зміни стану

Перемикання — ./switch-to-alerts-in-ua.sh (для зворотного напрямку — рядок у .env і docker compose up -d). Скрипт спершу перевіряє токен одноразовим контейнером (python -m app.checktoken) і не чіпає нічого, якщо токен неробочий або історія недоступна: інакше збирач пішов би в цикл рестартів і лишив дірку в даних.

Токен вводиться з клавіатури (read -rsp) і потрапляє тільки в .env з правами 600 — не в історію команд, не в аргументи процесів, не в логи docker.

Дублікати при зміні джерела. Різні джерела дають різні id тієї самої тривоги (siren — синтетичні, alerts.in.ua — справжні), тому за період, покритий обома, count подвоюється. total_seconds не страждає — він рахується як об'єднання інтервалів. Рядки siren відрізняються виставленим 61-м бітом id, тож чистяться одним точним запитом:

DELETE FROM alerts WHERE id >= 2305843009213693952;  -- 1 << 61

Жовтий і червоний рівні

З 07.09.2026 джерела розрізняють два рівні тривоги: червоний (ракетна, балістична, комбінована загроза) і жовтий (дрони). Це не новий alert_type — тип лишається air_raid, а колір приїжджає окремим полем:

Джерело Поле
alerts_in_ua alert_level: "red" | "yellow" на верхньому рівні тривоги
siren activeAlerts[].activeAlertLevels[].alertLevel: "Red" | "Yellow" (+ текстова reason)
ubilling немає — у фіді лише булеве alertnow

У siren рівнів у списку буває більше одного одночасно (спостерігав 1 випадок з 47) — беремо старший, червоний важливіший за жовтий.

У базі це окрема колонка alert_level, а не значення в alert_type. Розширювати тип було б декартовим добутком: air_raid_yellow тихо зламав би кожен WHERE alert_type='air_raid' і зробив би 15 тис. наявних рядків іншим типом, ніж нові.

Рівень навмисно не входить у ключ synthetic_id(). Тривога може перетекти з жовтої в червону; якби колір був у ключі, той самий інцидент отримав би другий id — дублікат замість оновлення рядка.

До 07.09.2026 розбивки немає. Історичний ендпоінт віддає ті записи з alert_level="red", але це значення за замовчуванням, а не оцінка загрози: у місячному вікні на 07.09 найраніша yellow2026-09-07T10:35:39Z (Дніпропетровщина, 3 записи з 2697), а по Харківщині, Запоріжжю й Києву за весь місяць жодної. Тому дата ввімкнення тримається в meta.levels_since, а UI за ранішні дати не показує ані зведення, ані кольору смужок, ані міток у списку — той самий прийом, що й coverage_start. Backfill і далі пише в базу те, що каже API; від вигаданої статистики захищає саме levels_since.

Як працює збирач

  • live — раз на 45 с питає джерело, хто зараз під тривогою, і пише в SQLite. Тривоги, що зникли зі списку, закриваються часом останнього спостереження (finished_source='poller'). Початок тривоги береться з фіду, тому він точний; оцінкою є лише кінець, з точністю до інтервалу опитування.
  • каталог регіонів — раз на добу підтягується з джерела (siren віддає повне дерево область/район/громада); статичний каталог із regions.py лишається запасним варіантом.
  • backfill — тільки для alerts_in_ua: раз на 12 год обхід усіх областей через month_ago.

Момент старту збору фіксується окремо (coverage_start) і не виводиться з MIN(started_at): у фіді є тривоги, що тривають з 2022 року (Луганщина, Крим), і по них будь-яка минула дата виглядала б покритою. Тому UI чесно каже «за цю дату даних немає» замість того, щоб показати неправдиву цифру.

Стек

FastAPI + SQLite (WAL) + ванільний HTML/JS. Жодних фронтенд-фреймворків — сторінка має вантажитись через тунель з мобільного інтернету.

app/
  main.py        FastAPI: /api/regions, /api/stats, /api/status + статика
  poller.py      фоновий збирач (окремий контейнер, той самий образ)
  db.py          схема SQLite + правила upsert/закриття тривог + каталог регіонів
  regions.py     каталог uid → назва: спершу БД, статика як запасний варіант
  alerts_api.py  низькорівневий клієнт alerts.in.ua (Bearer, If-Modified-Since)
  providers/
    base.py         інтерфейс джерела + нормалізація часу + синтетичні id
    siren.py        siren.pp.ua — ukrainealarm v3, без ключа, з громадами
    ubilling.py     ubilling.net.ua — області, без ключа
    alerts_in_ua.py alerts.in.ua — з ключем, з історією
  static/        index.html, style.css, app.js
Dockerfile
docker-compose.yml
.env.example

API

Ендпоінт Опис
GET /api/regions дерево областей з районами для селектора
GET /api/stats?uid=31&date=2026-09-03&scope=with_children&alert_type=air_raid статистика за добу
GET /api/range?uid=31&from=2026-08-08&to=2026-09-06 подобові підсумки за період — для режиму порівняння
GET /api/status скільки записів у базі, глибина історії, час останнього опитування
GET /api/docs автодокументація OpenAPI

/api/range тягне весь період одним запитом до БД і розкладає по добах у пам'яті: 30 окремих запитів через тунель з мобільного вантажились би помітно довше. Максимум — 92 доби. Середні рахуються лише по покритих добах, інакше порожні дні до початку збору тягнули б середнє вниз.

scope=with_children (типово) додає до регіону всіх його нащадків на будь-яку глибину (область → райони → громади) і об'єднує перекривні інтервали, щоб одна тривога, оголошена і по області, і по громаді, не рахувалась двічі. У відповіді:

  • total_seconds — об'єднаний час під тривогою (це головна цифра),
  • sum_seconds — проста сума окремих тривог,
  • count — кількість окремих записів,
  • coveragefull / partial / none: чи покрита доба збором даних,
  • levels — розбивка {red, yellow, unknown}, а levels_known каже, чи має сенс її показувати (див. «Жовтий і червоний рівні»).

Тривоги, що переходять через північ, обрізаються межами доби (clipped: true), тобто нічна тривога чесно ділиться між двома днями.

Порядок запуску після рестарту хоста

Якщо сервіси публікують порти на <BIND_ADDR> — адресі інтерфейсу wg0. Штатно docker.service і wg-quick@wg0 обидва впорядковані лише After=network-online.target, а між собою порядку не мають. Якщо docker стартує раніше за тунель, публікація падає з bind: cannot assign requested address.

Лікується drop-in'ом, який ставить setup-boot-order.sh (потрібен sudo, зачіпає всі контейнери на хості):

sudo ~/air-alerts/setup-boot-order.sh            # встановити й перевірити
sudo ~/air-alerts/setup-boot-order.sh --reboot   # ще й перезавантажити

Wants=, а не Requires=: якщо тунель раптом не підніметься, docker усе одно має стартувати — краще контейнери без публікації, ніж жодних контейнерів.

Розгортання на VPS

1. Джерело даних

Без токена — ALERTS_PROVIDER=siren, працює одразу (так проєкт і жив 03–05.09.2026). З токеном краще alerts_in_ua: історія за місяць одразу і підтверджені API часи завершення. Заявка на https://alerts.in.ua/, документація — https://devs.alerts.in.ua/.

2. Залити проєкт

З ноутбука (Git Bash):

tar czf - -C "$HOME/Documents/obsidian/10_Projects" --exclude='__pycache__' Air_Alerts_Stats | ssh <SSH_USER>@<SERVER_IP> -p <SSH_PORT> "mkdir -p ~/air-alerts && tar xzf - -C ~/air-alerts --strip-components=1"

3. Налаштувати і підняти

ssh <SSH_USER>@<SERVER_IP> -p <SSH_PORT>
cd ~/air-alerts
cp .env.example .env      # типово ALERTS_PROVIDER=siren, ключ не потрібен
docker compose up -d --build
docker compose logs -f poller

У логах має бути каталог регіонів оновлено: 1606 записів і далі GET https://siren.pp.ua/api/v3/alerts "HTTP/1.1 200 OK" кожні 45 с.

3b. Перемкнутись на alerts.in.ua, коли прийде токен

ssh <SSH_USER>@<SERVER_IP> -p <SSH_PORT> -t '~/air-alerts/switch-to-alerts-in-ua.sh'

Скрипт питає токен (введення приховане), перевіряє його одноразовим контейнером і перемикає джерело лише якщо той працює і віддає історію. Далі піде backfill: 27 областей × 35 с ≈ 16 хв.

Backfill доллє історію за останній місяць і зсуне coverage_start на 30 днів назад. Після нього варто прибрати дублікати за період, покритий обома джерелами (див. «Дублікати при зміні джерела» вище).

4. Перевірити, що порт не стирчить в інтернет

curl -s -o /dev/null -w '%{http_code}\n' http://<BIND_ADDR>:3001/api/status   # 200
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://<SERVER_IP>:3001/  # 000

ports: "<BIND_ADDR>:3001:8000" прив'язує публікацію виключно до інтерфейсу wg0. Docker у цьому випадку не створює DNAT-правило з 0.0.0.0, тож звична пастка «docker обходить ufw» тут не спрацьовує. Але перевірити все одно варто — це 5 секунд.

5. З телефону

Підняти WireGuard-профіль і відкрити http://<BIND_ADDR>:3001. Сторінка запам'ятовує обраний регіон у localStorage.

Два режими інтерфейсу

  • Одна доба — погодинна смуга + список тривог з часом і тривалістю кожної.
  • Порівняння днів — стовпчик на добу за 7/14/30/60 днів, перемикач «тривалість / кількість», тап по стовпцю показує цифри, подвійний — відкриває ту добу детально. Сірі стовпці — доби до початку збору.

Вісь дат — окремий рядок під діаграмою, а не підписи всередині стовпців: інакше вони йдуть за висотою стовпця й розкладаються шахами. Її padding — 9px проти 8px у діаграми, бо треба компенсувати 1px рамки; без цього зсув накопичується і на 60 днях сягає ~7px. Понад 20 днів підписи стають вертикальними, бо горизонтально числа вже не влазять.

Статика віддається з Cache-Control: no-cache, а в URL скрипта й стилів підставляється мітка версії з часу зміни файлу (?v=<mtime>). Без цього браузер на телефоні лишає стару app.js після оновлення й показує вчорашній застосунок — саме так і сталося при першому деплої порівняння.

Захист від випадкового коміту секретів

У репозиторії є pre-commit хук .githooks/pre-commit. Вмикається раз на клон:

git config core.hooksPath .githooks

Він зупиняє коміт, якщо в проіндексованому вмісті з'явився .env, файл бази чи рядок на кшталт TOKEN=<довге реальне значення> (плейсхолдери й звертання до змінних оточення ігноруються). Разовий обхід — git commit --no-verify.

Навіщо, якщо є GitHub secret scanning: безкоштовно він ловить лише секрети відомих провайдерів — AWS, Stripe, GitHub. Токен alerts.in.ua це довільний рядок, під жоден такий шаблон він не підпадає, а виявлення узагальнених патернів (non-provider patterns) вимагає платного GitHub Secret Protection. Тому остання лінія оборони тут локальна.

Бекапи

alerts-backup.sh + cron, наприклад 15 4 * * *.

Знімок робиться через VACUUM INTO, а не cat по файлу: база в режимі WAL і пишеться збирачем щохвилини, тож проста копія може зловити її посеред транзакції. Ротація виконується тільки після валідного свіжого архіву — інакше серія битих бекапів витіснила б справжні.

Перевірки перед тим, як архів визнати валідним: integrity_check, кількість тривог не менша за ALERTS_MIN_ROWS (типово 1000) і непорожній каталог регіонів. Зберігається 30 днів у ~/air-alerts/backups/.

# перевірити, що архів справді відновлюється
gunzip -c ~/air-alerts/backups/alerts-$(date +%F).db.gz > /tmp/r.db
python3 -c "import sqlite3;c=sqlite3.connect('/tmp/r.db');print(c.execute('PRAGMA integrity_check').fetchone()[0], c.execute('SELECT COUNT(*) FROM alerts').fetchone()[0])"

Обслуговування

# бекап бази: VACUUM INTO дає узгоджений знімок при активному WAL,
# на відміну від простого cat файлу, який може зловити базу посеред запису
docker compose exec -T web python -c "import sqlite3,sys; sqlite3.connect('/data/alerts.db').execute('VACUUM INTO ?', ['/data/bak.db'])"   && docker compose exec -T web sh -c 'cat /data/bak.db; rm /data/bak.db' > ~/backups/alerts-$(date +%F).db

# скільки зібрано
curl -s http://<BIND_ADDR>:3001/api/status | python3 -m json.tool

# оновлення після правок коду
cd ~/air-alerts && docker compose up -d --build

Том alerts-data переживає docker compose down. Знести дані назавжди — тільки docker compose down -v.

Відомі межі

  • Простій збирача = дірка в історії. З siren її нічим не залатати — історичного ендпоінта в нього немає. З alerts_in_ua наступний backfill підтягне все за останній місяць.
  • finished_source='poller' означає, що кінець тривоги — оцінка з точністю до інтервалу опитування (45 с). У списку такі позначені «кінець оцінено збирачем». Початок тривоги завжди точний: він береться з фіду, а не з моменту опитування.
  • siren.pp.ua — неофіційний проксі офіційного API. Якщо він зникне, це рядок у .env (ALERTS_PROVIDER=ubilling) і docker compose up -d; ціна — втрата гранулярності до районів і громад.
  • Ліміт 429 не валить збирач: запит пропускається, наступний цикл повторює.
  • Тривоги типів artillery_shelling / urban_fights збираються, але UI типово показує лише air_raid — інші доступні через ?alert_type=all в API.
  • Рівень тривоги зберігається останній відомий, історії його змін немає: для добової статистики знати, що тривога почалась жовтою і стала червоною, не потрібно. Масив threats з alerts.in.ua (drones, unspecified_missiles) не парситься — він є не в кожній тривозі.
  • З ubilling колонка alert_level лишається NULL, і UI рахує такі записи як «без рівня».

About

Статистика повітряних тривог за добу для будь-якого регіону України: власна історія в SQLite, самохостинг у Docker

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages