Статистика повітряних тривог за обрану добу для будь-якої області, району чи громади України: скільки тривог, скільки сумарно часу, коли саме, і як доба виглядає на тлі сусідніх.
Самохостинг: FastAPI + SQLite + ванільний фронтенд, два контейнери, без зовнішніх залежностей крім самого API тривог. Розрахований на те, щоб швидко вантажитись на телефоні через VPN.
Автентифікації в застосунку немає. Він задуманий як особистий сервіс за VPN або на localhost. Типовий
BIND_ADDR=127.0.0.1навмисне не виставляє його в мережу — не міняй на0.0.0.0, не поставивши перед ним щось, що питає пароль.
Жодне публічне джерело не віддасть «скільки тривог було 20 серпня» через півроку.
alerts.in.ua має історичний ендпоінт GET /v1/regions/{uid}/alerts/{period}.json,
але:
| Обмеження | Наслідок |
|---|---|
єдиний дозволений period — month_ago |
глибше ніж на місяць назад API не дивиться |
| ліміт на цей ендпоінт — 2 запити/хв | обійти 27 областей = ~15 хв на один прохід |
| загальний ліміт — 8–10 req/min (soft), 12 (hard) | live-опитування частіше ніж раз на ~6 с — 429 |
| потрібен токен за заявкою | і його можуть не дати |
Тому історію веде власна БД, а джерело даних — змінна величина. Від джерела потрібне рівно одне: «хто зараз під тривогою». Це дають і безключові агрегатори.
| Значення | Ключ | Гранулярність | Історія | Рівень | Примітка |
|---|---|---|---|---|---|
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 найраніша yellow — 2026-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
| Ендпоінт | Опис |
|---|---|
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— кількість окремих записів,coverage—full/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 усе одно
має стартувати — краще контейнери без публікації, ніж жодних контейнерів.
Без токена — ALERTS_PROVIDER=siren, працює одразу (так проєкт і жив 03–05.09.2026).
З токеном краще alerts_in_ua: історія за місяць одразу і підтверджені API часи
завершення. Заявка на https://alerts.in.ua/, документація — https://devs.alerts.in.ua/.
З ноутбука (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"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 с.
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 днів назад.
Після нього варто прибрати дублікати за період, покритий обома джерелами
(див. «Дублікати при зміні джерела» вище).
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/ # 000ports: "<BIND_ADDR>:3001:8000" прив'язує публікацію виключно до інтерфейсу wg0.
Docker у цьому випадку не створює DNAT-правило з 0.0.0.0, тож звична пастка
«docker обходить ufw» тут не спрацьовує. Але перевірити все одно варто — це 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 рахує такі записи як «без рівня».