Folder Watcher
26.10.2025       script       Python Flask Watchdog HTML CSS JavaScript
Folder Watcher

Лёгкий серверный инструмент для наблюдения за файлами в заданных каталогах. Следит за созданием, удалением, перемещением и изменениями файлов, логирует события в JSONL, показывает их в веб-интерфейсе и отправляет уведомления в Telegram и (опционально) в Zabbix. Предназначен как личный «страж» проекта: мало кода, низкая нагрузка, простая интеграция с внешними средствами оповещений.

Содержание

 

Введение

Актуальность проекта

Цель

Теоретическая часть

Библиотеки

Инструменты создания

Структура директории проекта

Код

Серверный слой – app.py

Модуль watcher.py

Веб‑интерфейс – index.html

Диаграммы

Оптимизация

Практическая часть

1. Создание папки проекта

2. Создание виртуальной среды разработки

3. Установка библиотек

4. Создание файлов проекта

5. Написание кода

6. Тестирование

7. Создание бота Telegram

8. Привязка бота и проверка уведомлений

9. Установка Docker (по желанию)

10. Установка и настройка Zabbix

11. Привязка Zabbix и проверка работы

12. Проверка работоспособности и отладка

Заключение, ссылки и код

 

Введение

 

Актуальность проекта

 

Создание проекта началось с заботы о безопасности моего сервера. Нет, у меня не было обнаруженных уязвимостей — я регулярно усиливал защиту. Тем не менее, как бы ни был уверен в проделанной работе, время от времени я всё равно задаюсь вопросом: «Всё ли в порядке? Не получили ли доступ к внутренним файлам проекта?» Часто я могу не заходить на сервер неделями, а знать о состоянии файлов хотелось бы постоянно. Так родилась идея инструмента для регулярного мониторинга директорий любого проекта на диске.

 

Цель

 

На моём телефоне три приложения, которыми я пользуюсь каждый день: WhatsApp, Telegram и Instagram. Самым удобным каналом оповещений мне кажется Telegram: у него простая процедура создания личного бота и удобный формат личных сообщений. Кроме того, бот может принимать команды — например, одна команда «drop» позволяет оперативно отключить сетевое соединение сервера. Если проект окажется полезным и корпоративным пользователям, важно также отправлять логи в централизованную систему мониторинга, например Zabbix.

 

Итоговая идея Folder Watcher — получить лёгкого, но эффективного «стража», который следит за проектом, пока вы не на сервере. Он должен уведомлять только по необходимости и предоставлять понятные отчёты: «Включить сигнал тревоги?» или «Вот список того, кто, когда и что сделал, пока вы спали». Простота — это фишка: меньше кода — меньше нагрузки на систему и больше возможностей для кастомизации без глубоких знаний внутренней структуры.

 

Теоретическая часть

 

Библиотеки

 

  1. watchdog - следит за изменениями в файловой системе — кто, что и когда создал, удалил, переместил или изменил. следит за изменениями в файловой системе — кто, что и когда создал, удалил, переместил или изменил.
  2. queue – безопасная очередь для обмена данными между потоками.
  3. threading - создаёт параллельные потоки, чтобы разные части программы (слежение, Telegram, Zabbix, UI) не мешали друг другу.
  4. queue и threading используем, ч тобы интерфейс не зависал, пока Telegram отправляет сообщение, а наблюдатели могли работать параллельно. Например, один поток отслеживает папку, другой пишет в лог, третий шлёт уведомления — и всё это спокойно уживается в одной программе.
  5. requests - Простая библиотека для работы с HTTP-запросами. Мы используем её для общения с Telegram API — отправляем и получаем сообщения.
  6. subprocess - Позволяет запускать системные команды прямо из Python. Через него мы отправляем сообщения в Zabbix и выполняем системные команды для отключения сети при тревоге.
  7. logging - Позволяет писать аккуратные записи о происходящем: от ошибок до системных событий.  В основном, получаем от него ошибки, если они возникают.
  8. json - Сериализует и десериализует данные (преобразует их в удобный формат JSON).
  9. datetime и time - Дают время и дату, считают интервалы и форматируют метки событий.
  10. socket - Работает с сетевыми соединениями, IP и портами. Чтобы определить локальный IP-адрес пользователя — мы добавляем его к каждому событию в UI. Это особенно полезно, когда система запущена на нескольких машинах: можно понять, с какого компьютера пришёл сигнал.
  11. collections.deque - кольцевая очередь для корректного отображения таблицы событий.
  12. FastAPI - это минималистичный и быстрый сервер на Python. Он обрабатывает HTTP-запросы от фронтенда: запуск слежения, получение событий, настройку Telegram и Zabbix. Чтобы проект был кроссплатформенным: один код работает и на macOS, и на Windows, и на Linux. Не нужно собирать EXE — достаточно открыть браузер и видеть все события вживую.
  13. venv – Виртуальная независимая среда разработки.
  14.  

Инструменты создания

 

  1. VS Code
  2. Terminal x2
  3. Docker + Zabbix
  4. Activity Monitor

 

Структура директории проекта

 

|-- 📁 folderwatcher
|   |-- 📄 app.py
|   |-- 📄 config.json
|   |-- 📄 events.log
|   |-- 📄 index.html
|   +-- 📄 watcher.py
|-- 📁 test_root
+-- 📁 venv

 

Код

 

Серверный слой – app.py

 

Файл app.py описывает HTTP‑сервер на базе Flask. Он определяет несколько REST‑эндпоинтов для управления мониторингом и интеграциями.

 

Основные объекты

 

Создание приложения. В начале файла создаётся экземпляр Flask и глобальный объект WatchManager, который управляет наблюдателями за файлами.

Функция index(). Это обработчик корневого URL /. Вместо использования шаблонов функция просто возвращает файл index.html из корня проекта, что позволяет работать даже при отсутствии папки templates.

 

API-эндпоинты

 

Эндпоинт

Метод

Описание

Основные вызовы

/api/start_monitoring

POST

Запускает мониторинг для указанных путей. Клиент передаёт список путей и исключаемых паттернов. Функция приводит пути к абсолютному виду, задаёт путь к файлу лога при необходимости, настраивает паттерны исключения и вызывает watch_manager.start_monitoring.

watch_manager.set_log_path, watch_manager.set_exclude_patterns, watch_manager.start_monitoring

/api/stop_monitoring

POST

Останавливает все наблюдатели, вызывая watch_manager.stop_monitoring().

watch_manager.stop_monitoring

/api/configure_telegram

POST

Принимает token и chat_id, вызывая watch_manager.configure_telegram для настройки отправки уведомлений в Telegram.

watch_manager.configure_telegram

/api/configure_zabbix

POST

Настраивает отправку ловушек в Zabbix (server, host, key) через watch_manager.configure_zabbix.

watch_manager.configure_zabbix

/api/events

GET

Возвращает последние события из кольцевого буфера UI. Клиент может указать since_seq (последний видимый номер события) и limit. Обработчик вызывает watch_manager.get_events().

watch_manager.get_events

/api/download_log

GET

Отдаёт файл лога events.log, если он существует. Использует путь из watch_manager.log_path.

/static/<path>

GET

Служит для отдачи статических файлов (CSS, JS) из папки static.

send_from_directory

 

В конце app.py запускает сервер с портом, взятым из переменной окружения PORT (по умолчанию 5000).

 

Модуль watcher.py

 

Этот модуль содержит все службы, отвечающие за отслеживание событий файловой системы и отправку уведомлений. Логика разбита на несколько классов.

 

Класс TelegramNotifier

 

Обеспечивает асинхронную отправку сообщений в Telegram.

Инициализация. При создании принимает токен и идентификатор чата. Если оба значения заданы и библиотека requests доступна, включается режим отправки (enabled=True). Для отправки создаётся очередь _q и запускается worker‑поток _run.

Отправка сообщения (send_message). Метод помещает текст в очередь с пометкой parse_mode='HTML' и немедленно возвращается. Если очередь заполнена, сообщение отбрасывается с предупреждением в лог.

Фоновый поток _run. Worker извлекает элементы из очереди и отправляет их через Telegram API. Перед каждой отправкой соблюдается ограничение скорости (_respect_rate_limit), позволяющее не превышать ~20 запросов в секунду. Если отправка завершается ошибкой, используется повторная попытка с экспоненциальной задержкой, а при ошибке 400 удаляется parse_mode и выполняется повтор.

 

Класс ZabbixNotifier

 

Используется для отправки trap‑сообщений в Zabbix через утилиту zabbix_sender.

При инициализации ищет zabbix_sender в системном PATH и популярных путях. Если программа не найдена, отправка отключается и в лог выводится ошибка.

send_trap и send_event_json создают и запускают отдельный поток, который вызывает команду zabbix_sender с нужными параметрами. Это позволяет не блокировать основной поток при отправке.

 

Класс TelegramDropListener

 

Реализует фонового слушателя Telegram‑бота. Периодически опрашивает метод getUpdates у бота и проверяет входящие сообщения. Если автор сообщения соответствует настроенному chat_id и текст равен "drop", вызывается callback (например, отключение сети). Поток запущен демоном и прекращается методом stop().

 

Класс RansomwareDetector

 

Простой датчик массовых изменений. Поддерживает скользящее окно (window_seconds) и порог количества событий (threshold). Метод record_event() добавляет временную метку в список и, если за окно накопилось ≥ threshold событий, вызывает callback (например, отправка предупреждения о возможном шифровальщике).

 

Класс DebounceFilter

 

Фильтрует повторяющиеся события для одного и того же пути и типа события в течение debounce_seconds. Метод should_emit(path, event_type) проверяет время последнего события и решает, пропускать ли новое событие.

 

Класс EventProcessor (обработчик событий)

 

Этот класс наследует FileSystemEventHandler из watchdog и реагирует на события файловой системы. В конструкторе он получает:

Очередь событий event_queue для передачи данных в UI.

Список шаблонов исключения exclude_patterns.

Путь к файлу лога.

Инстансы TelegramNotifier и ZabbixNotifier для отправки уведомлений.

RansomwareDetector и DebounceFilter для дополнительной логики.

Callback ui_push для добавления событий в кольцевой буфер UI.

 

Внутренние методы

 

_write_log() – записывает словарь события в JSONL‑файл лога.

_is_excluded(path) – проверяет, попадает ли путь под исключающие маски. Маски, начинающиеся с точки, трактуются как суффикс имени; другие ищутся как подстроки.

_emit_ui_and_tg(action, path, src_path, dest_path) – формирует словарь события с человеком читаемым описанием, помещает его в очередь и кольцевой буфер UI, а при наличии настройки Telegram отправляет сообщение пользователю. Категория события определяется по русскому тексту действия (создание, удаление, изменение, перемещение).

_schedule_modified_emit(path) – откладывает отправку события «Файл изменён» на короткое время (0,5 сек), чтобы различать фактическое изменение от последующего удаления/перемещения. Если за время ожидания файл был удалён или перемещён, событие изменения не будет выведено.

 

Обработчики событий Watchdog

 

on_created(event) – вызывается при создании файла/каталога

Проверяет исключения и дребезг. Логирует событие, отправляет его в Zabbix (если настроено) и в детектор вымогателя, обновляет время последнего created, формирует человеко‑читаемое действие («Файл создан» или «Папка создана») и отправляет в UI/Telegram.

Использует: _write_log, zabbix_notifier.send_event_json, ransomware_detector.record_event, _emit_ui_and_tg

 

on_deleted(event) – вызывается при удалении файла/каталога

Аналогично on_created: проверяет исключения и дребезг, пишет в лог, оповещает Zabbix, детектор шифровальщика; записывает время последнего «терминального» действия, удаляет ожидающее событие modified и формирует действие «Файл удалён» или «Папка удалена».

Использует: _write_log, zabbix_notifier.send_event_json, ransomware_detector.record_event, _emit_ui_and_tg

 

on_modified(event) – вызывается при изменении файла

Игнорирует каталоги и подавляет событие, если оно следует сразу после on_created. Использует Debounce-Filterдля исключения повторов. Записывает событие в лог, отправляет в Zabbix/детектор, сохраняет время начала изменения и запускает отложенное оповещение _schedule_modified_emit.

Использует: _write_log, zabbix_notifier.send_event_json, ransomware_detector.record_event, _schedule_modified_emit

 

on_moved(event) – вызывается при перемещении файла/каталога

Проверяет исключения и дребезг, пишет в лог, оповещает Zabbix и детектор, записывает время «терминального» действия и удаляет ожидающее событие modified. Формирует действие «Файл перемещён» или «Папка перемещена» и отправляет его в UI/Telegram.

Использует: _write_log, zabbix_notifier.send_event_json, ransomware_detector.record_event, _emit_ui_and_tg

 

Класс WatchManager

 

WatchManager объединяет все сервисы: создаёт наблюдателей, хранит конфигурацию, управляет кольцевым буфером UI и обеспечивает интеграцию с Telegram/Zabbix.

 

Ключевые поля и инициализация

 

  • observers – список объектов Observer от watchdog.
  • event_queue – очередь для событий, передаваемых в UI.
  • exclude_patterns – список паттернов исключения.
  • log_path – путь к файлу лога. По умолчанию events.log в рабочей директории.
  • telegram_notifier и zabbix_notifier – экземпляры соответствующих классов.
  • ransomware_detector и debounce_filter – средства для фильтрации и детекции.
  • ui_events – двусторонняя очередь (deque) ограниченного размера, хранящая последние события для веб‑интерфейса.
  • user_name и local_ip – информация о пользователе и IP‑адресе, добавляемая в каждое событие.
  • Настройка конфигурации Telegram восстанавливается из файла config.json при запуске через метод _load_config().

 

Методы управления

 

  • _push_ui(event) – добавляет событие в кольцевой буфер и увеличивает счётчик seq. Также заполняет поля user_name и user_ip по умолчанию.
  • get_events(since_seq, limit) – возвращает список событий с номером seq больше since_seq, что позволяет клиенту получать только новые записи.
  • configure_telegram(token, chat_id) – пересоздаёт TelegramNotifier и запускает TelegramDropListener (если заданы оба параметра). Старые сервисы останавливаются. Конфигурация сохраняется в файл.
  • configure_zabbix(server, host, key, port) – заменяет текущий ZabbixNotifier на новый экземпляр.
  • set_exclude_patterns(patterns) – сохраняет список исключаемых паттернов.
  • set_log_path(path) – изменяет путь к файлу лога.
  • _on_ransomware_detected() – вызывается RansomwareDetector при превышении порога. Формирует предупреждение и отправляет его в UI, Telegram и Zabbix.
  • _on_drop() – вызывается TelegramDropListener, если пользователь в чате написал сообщение drop. Выводит сообщение в консоль и пытается отключить сетевые интерфейсы через _disable_network.
  • _disable_network() – определяет операционную систему и пытается отключить сети различными утилитами (nmcli/ip для Linux, networksetup для macOS, PowerShell для Windows).
  • start_monitoring(paths) – главный метод запуска наблюдения. Если мониторинг уже запущен, сначала останавливает наблюдателей и сбрасывает состояние (буфер, счётчик, очередь). Затем для каждого указанного каталога создаёт экземпляр EventProcessor и регистрирует его в новом объекте Observer с рекурсивным отслеживанием. Ссылки на наблюдатели сохраняются в observers.
  • stop_monitoring() – перебирает все объекты Observer, вызывает stop() и join() для безопасного завершения потоков, очищает список observers и сбрасывает флаг monitoring_active.

 

Общий поток обработки событий

 

Ниже приведена упрощённая диаграмма взаимодействия основных компонентов на сервере:

 

        Клиентский вызов /api/start_monitoring
                    |
                    v
      +------------------------------+
      | WatchManager.start_monitoring|
      +------------------------------+
                    |
      Создаём EventProcessor и Observer
                    |
      Observer → отслеживает файловую систему (watchdog)
                    |
      +--------------------------------+
      | EventProcessor (watchdog handler)|
      +--------------------------------+
       |         |         |         |
       v         v         v         v
     on_created on_deleted on_modified on_moved
       |         |         |         |
       v         v         v         v
  _write_log,   _write_log,… (логирование)
  zabbix_notifier.send_event_json (если настроено)
  ransomware_detector.record_event → при пороге вызывает _on_ransomware_detected
  debounce_filter (фильтрация дребезга)
  _emit_ui_and_tg → помещает событие в очередь UI,
                   отправляет Telegram‑уведомление
                    |
                    v
      WatchManager._push_ui → кольцевой буфер + seq

 

Веб‑интерфейс – index.html

 

Основные элементы:

  • Настройки мониторинга: пользователи могут добавлять несколько путей для отслеживания, указывать расширения/маски для исключения и запускать/останавливать мониторинг.
  • Интеграции: поля для ввода Telegram Bot Token и Chat ID, а также параметров Zabbix (сервер, хост, ключ). Кнопки отправляют соответствующие POST‑запросы в API /api/configure_telegram и /api/configure_zabbix.
  • Журнал событий: таблица, в которой отображаются полученные события. Клиент периодически опрашивает /api/events с помощью функции fetchEventsOnce и обновляет таблицу. Пользователь может фильтровать события по типу, искать по пути, сортировать по различным столбцам и переключаться между табличным видом и JSON‑представлением.
  • Скачивание журнала: кнопка вызывает downloadLog() и открывает /api/download_log, скачивая лог‑файл на диск.

 

Скрипт JavaScript реализует:

 

  1. Работу с DOM: добавление и удаление полей для путей (addPath, removePath), сбор значений для отправки на сервер.
  2. Запросы к API: отправку POST‑запросов для запуска/остановки мониторинга и настройки интеграций. Используются функции startMonitoring, stopMonitoring, configureTelegram, configureZabbix.
  3. Поллинг событий: функция startPolling запускает ленивый цикл, который периодически вызывает fetchEventsOnce. Эта функция получает новые события, обновляет глобальный буфер eventsBuffer, сортирует и отображает данные в таблице.
  4. Фильтрация и сортировка: при смене фильтра или строки поиска вызывается redrawAll, который применяет фильтры, сортирует события (метод compareEvents) и строит строки таблицы. Сортировка по IP преобразует адрес в число для корректного сравнения.
  5. Сохранение настроек: с помощью localStorage запоминаются токен/чат Telegram, параметры Zabbix и введённые пути, чтобы после перезагрузки страницы они восстанавливались.

 

Диаграммы

 

Запуск мониторинга

 

Клиент вызывает /api/start_monitoring
      |
      v
Flask‑функция api_start_monitoring
      |
      v
WatchManager.set_log_path / set_exclude_patterns
      |
      v
WatchManager.start_monitoring(paths)
      |
      +--> for each path:
             create EventProcessor
             schedule on Observer
             Observer.start()

 

Обработка события файловой системы

 

Событие watchdog (создание/удаление/изменение/перемещение)
      |
      v
EventProcessor.on_* callback
      |
      +--> проверка _is_excluded и DebounceFilter
      |
      +--> запись в лог (_write_log)
      |
      +--> отправка в Zabbix (если есть)
      |
      +--> RansomwareDetector.record_event
      |
      +--> добавление в очередь UI (_emit_ui_and_tg)
              |
              +--> WatchManager._push_ui → кольцевой буфер
              |
              +--> TelegramNotifier.send_message (если включён)

 

Детекция массовых изменений

 

EventProcessor → RansomwareDetector.record_event
      |
      v
Если >= threshold событий за window_seconds:
      |
      v
WatchManager._on_ransomware_detected
      |
      +--> формирование события с alert=True
      +--> _push_ui (UI)
      +--> TelegramNotifier.send_message
      +--> ZabbixNotifier.send_event_json

 

Получение команды «drop»

 

TelegramDropListener ← фоновые сообщения бота
      |
      v
Получено сообщение "drop" от настроенного chat_id
      |
      v
WatchManager._on_drop
      |
      +--> выводит сообщение в консоль
      +--> вызывает _disable_network
             |
             +--> на Linux: nmcli off / ip link down
             +--> на macOS: networksetup -setnetworkserviceenabled off
             +--> на Windows: Disable-NetAdapter через PowerShell

 

Оптимизация

 

  • до оптимизации: CPU 86–100%, RAM 370 MB
  • после объединения потоков и уменьшения интервала polling: CPU 0.1–0.5%, RAM 40 MB

     

Практическая часть

 

1. Создание папки проекта

 

1. Откройте терминал и перейдите в удобный каталог для разработки.  

2. Создайте новую папку для проекта:

 

mkdir fs_monitor
cd fs_monitor

 

Эта папка будет содержать исходный код веб‑приложения и вспомогательные файлы.

 

2. Создание виртуальной среды разработки

 

Для изоляции зависимостей удобнее работать в виртуальной среде:

 

pip install venv
python3 -m venv venv
source venv/bin/activate  # Linux/Mac
venv\Scripts\activate     # Windows

 

Активация окружения позволит установить нужные библиотеки, не влияя на систему.

 

3. Установка библиотек

 

Установите необходимые зависимости:

 

pip install flask watchdog requests python-telegram-bot

 

Если ваша IDE не «видит» установленные пакеты, перезапустите её или убедитесь, что она использует правильный интерпретатор из виртуального окружения.

 

4. Создание файлов проекта

 

Проект состоит из трёх основных файлов и каталога `static` для статических ресурсов:

 

1. app.py — точка входа Flask‑приложения. В нём настраиваются маршруты (`/api/start_monitoring`, `/api/stop_monitoring`, `/api/configure_telegram`, `/api/configure_zabbix`, `/api/events`, `/api/download_log`) и поднимается HTTP‑сервер.  

2. watcher.py — содержит классы для отслеживания событий: `TelegramNotifier` для отправки сообщений в Telegram, `ZabbixNotifier` для отправки trap‑сообщений через `zabbix_sender`, `EventProcessor` для обработки событий файловой системы и их фильтрации, `WatchManager` для управления наблюдателями, исключениями и интеграциями.  

3. index.html — веб‑страница. Она позволяет вводить пути для мониторинга, управлять исключениями, настраивать интеграции, просматривать журнал событий и скачивать лог‑файл. В файле используется JavaScript для работы с API приложения.

 

Создайте эти файлы в корне проекта и заполните кодом. Дополнительно создайте папку `static`, если планируете выносить CSS или изображения отдельно.

 

5. Написание кода

 

Код организован следующим образом:

 

- В `watcher.py` класс `TelegramNotifier` использует токен и идентификатор чата, чтобы отправлять сообщения в Telegram через `https://api.telegram.org/bot<TOKEN>/sendMessage`. Потребность в библиотеке `requests` обусловлена обращениями к Telegram API.  

- `ZabbixNotifier` ищет в системе утилиту `zabbix_sender` и запускает её в отдельном потоке, передавая на сервер Zabbix значения ключей.  

- `EventProcessor` наследуется от обработчика `watchdog` и реагирует на события создания, удаления, изменения и перемещения файлов/папок. Для борьбы с «дребезгом» реализован класс `DebounceFilter`, чтобы игнорировать частые повторяющиеся события. 

- `WatchManager` управляет несколькими наблюдателями (`Observer`) и обеспечивает буферизацию событий для UI. Он также сохраняет параметры Telegram в файл `config.json` и автоматически поднимает слушатель команд `drop`, который выключает сетевые интерфейсы, если пользователь отправит боту слово «drop».

 

В `app.py` настраивается Flask‑сервер, который предоставляет API для запуска/остановки мониторинга, настройки Telegram и Zabbix, выгрузки лога и передачи событий на клиентскую часть. При запуске приложение отдаёт страницу `index.html`.

 

6. Тестирование

 

Запустите сервер:

 

python3 app.py

 

По умолчанию приложение слушает `http://127.0.0.1:5000/`. Откройте эту страницу в браузере. В веб‑интерфейсе добавьте путь к папке, которую хотите мониторить, укажите исключения (например `.log,.tmp`) и нажмите «Запустить мониторинг». В таблице будут появляться новые строки при создании, удалении, изменении и перемещении файлов. 

Для удобства можно сортировать таблицу по столбцам, фильтровать события и искать по части пути. 

 

 

Проверьте, что в локальный лог‑файл `events.log` записываются события в формате JSON, а при прекращении мониторинга (`Остановить мониторинг`) наблюдатели корректно останавливаются.

 

7. Создание бота Telegram

 

Для получения уведомлений в Telegram создайте собственного бота:

 

1. В Telegram найдите пользователя @BotFather.  

2. Отправьте команду `/newbot` и следуйте инструкциям: придумайте имя бота и его логин. BotFatherсоздаст бота и выдаст токен.  

   Токен — это длинная строка, формата `123456:ABC-DEF1234_xxx_xxx`, которая позволит приложению отправлять сообщения от имени бота.  

3. Получите идентификатор чата (chat ID). Для этого можно написать сообщение созданному боту и, используя любой бот‑просмотрщик, узнать ваш chat ID, либо временно вызвать `getUpdates` через API Telegram.

 

8. Привязка бота и проверка уведомлений

 

Откройте в браузере раздел «Интеграции» на вашей странице `index.html`. Введите полученные BotToken и Chat ID и нажмите «Привязать Telegram».  

Приложение отправит данные в `/api/configure_telegram`, сохранит их в `config.json` и запустит фоновый слушатель команд `drop`. Если всё прошло успешно, вы увидите сообщение об успешной настройке.  

Теперь при изменении файлов в отслеживаемых папках приложение отправит уведомление в ваш чат. Проверьте это, создав или удалив файл в мониторируемой папке.

 

 

9. Установка Docker (по желанию)

 

Если вы хотите развернуть приложение в контейнере, предварительно установите Docker. Официальный сайт предоставляет инструкции для разных операционных систем (Ubuntu, Debian, Fedora, Windows и др.), выбирайте подходящий вариант. После установки:

 

1. Создайте файл `Dockerfile` в корне проекта, чтобы собрать образ:

 

FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir flask watchdog requests
EXPOSE 5000
CMD ["python", "app.py"]

 

2. Соберите образ и запустите контейнер:

 

docker build -t fs_monitor:latest .
docker run -d -p 5000:5000 --name fs_monitor fs_monitor:latest

 

3. Проверьте доступность приложения по порту 5000.  

4. Контейнеризация упрощает переносимость и развертывание на сервере.

 

Логин и пароль:


Login:    Admin

Password: zabbix

 

10. Установка и настройка Zabbix

 

Zabbix — система мониторинга, которая может принимать trap‑сообщения из нашего приложения. Для её установки воспользуйтесь официальным руководством. На сайте Zabbix есть разделы «Quickstart», «Download» и «Install», которые позволяют выбрать версию сервера и инструкции для вашей платформы. 

Или пропишите следующее в терминале:

 

brew install zabbix
brew install --cask docker
open -a Docker    
mkdir -p ~/zbx && cd ~/zbx
cat > docker-compose.yml <<'YAML'
version: "3.8"
services:
  db:
    image: postgres:16
    container_name: zbx-db
    environment:
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: zabbix
      POSTGRES_DB: zabbix
    volumes:
      - zbx-db:/var/lib/postgresql/data
    restart: unless-stopped
  zabbix-server:
    image: zabbix/zabbix-server-pgsql:alpine-latest
    container_name: zbx-server
    environment:
      DB_SERVER_HOST: db
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: zabbix
      POSTGRES_DB: zabbix
    depends_on: [db]
    ports:
      - "10051:10051"  # порт для zabbix_sender
    restart: unless-stopped
  zabbix-web:
    image: zabbix/zabbix-web-nginx-pgsql:alpine-latest
    container_name: zbx-web
    environment:
      DB_SERVER_HOST: db
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: zabbix
      POSTGRES_DB: zabbix
      PHP_TZ: "Asia/Jerusalem"
    depends_on: [db, zabbix-server]
    ports:
      - "8080:8080"  # веб-интерфейс
    restart: unless-stopped
volumes:
  zbx-db:
YAML

 

docker compose up -d

 

После развёртывания сервера Zabbix:

 

1. Создайте новый host (например, `fs_monitor`) и добавьте элемент данных типа «Trap» с ключом, который вы укажете в приложении (например, `fs.events`).  

2. Установите или убедитесь, что на вашей машине доступна утилита `zabbix_sender` — она идёт в составе пакета агента Zabbix.  

3. Настройте триггер для генерации предупреждений, если в сообщения приходит флаг `"alert":1` (детектор массовых изменений).

 

11. Привязка Zabbix и проверка работы

 

В разделе «Интеграции» страницы `index.html` заполните поля Сервер (адрес сервера Zabbix), Хост(имя хоста) и Ключ (ключ элемента данных), затем нажмите «Привязать Zabbix». Приложение вызовет `/api/configure_zabbix`, настроит отправку сообщений и попробует найти `zabbix_sender` на вашей системе.  

Сгенерируйте несколько событий в отслеживаемых папках. В веб‑интерфейсе Zabbix проверьте, что элементы данных обновляются, и что триггеры срабатывают при большом числе изменений.

 

 

12. Проверка работоспособности и отладка

 

1. Убедитесь, что функции запуска и остановки мониторинга работают корректно: после нажатия «Остановить мониторинг» событие прекращается и новые события не фиксируются.  

2. Проверьте, что исключения (mask) фильтруют нужные расширения.  

3. Наблюдайте, что при массовых изменениях файлов срабатывает детектор и в Telegram/Zabbixотправляется предупреждение.  

4. Если уведомления не приходят, проверьте корректность токена и chat ID Telegram; убедитесь, что сервер Zabbix доступен и ключ элемента данных совпадает.  

5. При необходимости включите подробный лог в консоли или проверяйте файл `events.log`.

 

Заключение, ссылки и код

 

Ниже представлены вспомогательные ссылки, а также код, который можно просто вырезать и вставить в файлы.

 

https://www.docker.com/products/docker-desktop/

https://web.telegram.org/k/#@BotFather

https://www.zabbix.com/documentation/current/en/

 

watcher.py

 

import os
import json
import threading
import queue
import time
import logging
import subprocess
from datetime import datetime
import socket
from collections import deque
from typing import Optional
import shutil
from watchdog.observers import Observer
from watchdog.events import (
    FileSystemEventHandler,
    FileModifiedEvent,
    FileCreatedEvent,
    FileDeletedEvent,
    FileMovedEvent,
)
try:
    import requests
except ImportError:
    requests = None
class TelegramNotifier:
    """
    Queue + worker thread for sending messages to Telegram.
    - Uses `parse_mode=HTML` (safer than MarkdownV2 for arbitrary text).
    - Retries with exponential backoff on failures.
    - Detailed logging of HTTP status and response body on errors.
    - Fallback: on HTTP 400/Bad Request, resends without `parse_mode`.
    """
    def __init__(self, bot_token: str, chat_id: str | int):
        self.bot_token = (bot_token or "").strip()
        self.chat_id = str(chat_id).strip() if chat_id is not None else ""
        self.enabled = bool(self.bot_token and self.chat_id and requests is not None)
        self._q: "queue.Queue[dict]" = queue.Queue(maxsize=1000)
        self._stop_evt = threading.Event()
        self._worker: threading.Thread | None = None
        self._session = requests.Session() if requests is not None else None
        self._rate_limit_per_sec = 20
        self._last_ts = 0.0
        if self.enabled:
            self._start_worker()
    def _start_worker(self):
        if self._worker and self._worker.is_alive():
            return
        self._worker = threading.Thread(target=self._run, name="tg_notifier", daemon=True)
        self._worker.start()
    def stop(self):
        self._stop_evt.set()
        if self._worker:
            self._worker.join(timeout=2.0)
    def send_message(self, text: str):
        if not self.enabled:
            return
        try:
            self._q.put_nowait({"text": text, "parse_mode": "HTML"})
        except queue.Full:
            logging.warning("[TG] Queue is full, dropping message")
    def _respect_rate_limit(self):
        now = time.time()
        min_interval = 1.0 / self._rate_limit_per_sec
        if now - self._last_ts < min_interval:
            time.sleep(min_interval - (now - self._last_ts))
        self._last_ts = time.time()
    def _request(self, url: str, data: dict, timeout: float = 8.0):
        self._respect_rate_limit()
        return self._session.post(url, data=data, timeout=timeout)
    def _send_once(self, text: str, parse_mode: str | None) -> bool:
        url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
        payload = {"chat_id": self.chat_id, "text": text}
        if parse_mode:
            payload["parse_mode"] = parse_mode
        try:
            resp = self._request(url, payload)
            ok = resp.ok
            if not ok:
                logging.warning("[TG] sendMessage failed: %s %s | body=%s",
                                resp.status_code, resp.reason, resp.text[:1000])
            return ok
        except Exception as e:
            logging.error("[TG] Exception on sendMessage: %r", e)
            return False
    def _send_with_fallback(self, text: str, parse_mode: str | None) -> bool:
        ok = self._send_once(text, parse_mode)
        if not ok and parse_mode is not None:
            return self._send_once(text, None)
        return ok
    def _run(self):
        while not self._stop_evt.is_set():
            try:
                item = self._q.get(timeout=0.25)
            except queue.Empty:
                continue
            text = str(item.get("text", "")).strip()
            parse_mode = item.get("parse_mode")
            if not text:
                continue
            backoff = 0.7
            for _ in range(3):
                if self._send_with_fallback(text, parse_mode):
                    break
                time.sleep(backoff)
                backoff *= 2.0
class ZabbixNotifier:
    """Sends trap messages to Zabbix using `zabbix_sender` (asynchronously, non-blocking)."""
    def __init__(self, server: str, host: str, key: str, port: int = 10051):
        self.server = (server or "").strip()
        self.host = (host or "").strip()
        self.key = (key or "").strip()
        self.port = int(port) if port else 10051
        self.enabled = bool(self.server and self.host and self.key)
        # Try to locate `zabbix_sender` in PATH (common locations for macOS/Linux; Windows example path provided).
        self.sender_path = shutil.which("zabbix_sender")
        if not self.sender_path:
            for p in (
                "/opt/homebrew/bin/zabbix_sender",
                "/usr/local/bin/zabbix_sender",
                "C:\\Program Files\\Zabbix Agent\\zabbix_sender.exe",
            ):
                if os.path.exists(p) and os.access(p, os.X_OK):
                    self.sender_path = p
                    break
        if self.enabled and not self.sender_path:
            logging.error("[ZBX] zabbix_sender not found in PATH")
    def _run_sender(self, value: str):
        if not self.enabled or not self.sender_path:
            return
        def _send():
            try:
                cmd = [
                    self.sender_path,
                    "-z", self.server,
                    "-p", str(self.port),
                    "-s", self.host,
                    "-k", self.key,
                    "-o", value,
                ]
                res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
                if res.returncode != 0:
                    logging.warning("[ZBX] sender rc=%s out=%s", res.returncode, (res.stdout or"").strip())
            except Exception as e:
                logging.error("[ZBX] Exception on zabbix_sender: %r", e)
        threading.Thread(target=_send, name="zbx_sender", daemon=True).start()
    def send_trap(self, value: str):
        """Send an arbitrary string payload to the configured item key."""
        self._run_sender(value)
    def send_event_json(self, obj: dict):
        """Helper: serialize `obj` to JSON and send it via `zabbix_sender`."""
        try:
            payload = json.dumps(obj, ensure_ascii=False)
        except Exception:
            payload = str(obj)
        self._run_sender(payload)
class TelegramDropListener:
    """
    Lightweight incoming-message listener for Telegram.
    Periodically polls the `getUpdates` endpoint. If a message with the text "drop"
    arrives from the configured `chat_id`, invokes the provided callback.
    Runs in a dedicated daemon thread and stops automatically when `stop()` is called.
    """
    def __init__(self, bot_token: str, chat_id: str, callback=None):
        self.token = (bot_token or "").strip()
        self.chat_id = str(chat_id).strip() if chat_id is not None else ""
        self.callback = callback
        self._thread: threading.Thread | None = None
        self._stop_evt = threading.Event()
        self._last_update_id: int | None = None
        if self.token and self.chat_id and requests is not None:
            self._start()
    def _start(self):
        if self._thread and self._thread.is_alive():
            return
        self._thread = threading.Thread(target=self._run, name="tg_drop_listener", daemon=True)
        self._thread.start()
    def stop(self):
        self._stop_evt.set()
        if self._thread:
            self._thread.join(timeout=2.0)
    def _run(self):
        api_url = f"https://api.telegram.org/bot{self.token}/getUpdates"
        while not self._stop_evt.is_set():
            params = {"timeout": 15}
            if self._last_update_id is not None:
                params["offset"] = self._last_update_id + 1
            try:
                resp = requests.get(api_url, params=params, timeout=20)
                data = resp.json()
                if data.get("ok") and data.get("result"):
                    for update in data["result"]:
                        upd_id = update.get("update_id")
                        if upd_id is not None:
                            self._last_update_id = upd_id
                        msg = update.get("message") or update.get("edited_message")
                        if not msg:
                            continue
                        chat = msg.get("chat", {})
                        if str(chat.get("id")) != self.chat_id:
                            continue
                        text = str(msg.get("text", "")).strip().lower()
                        if text == "drop":
                            if self.callback:
                                try:
                                    self.callback()
                                except Exception:
                                    pass
                            else:
                                print("User requested drop")
                time.sleep(3.0)
            except Exception as e:
                logging.error("[TG_DROP] Error while polling updates: %r", e)
                time.sleep(10.0)
class RansomwareDetector:
    """
    Simple "mass change" detector: triggers if >= `threshold` events occur within `window_seconds`.
    Intended as a coarse heuristic to raise an alert on suspicious spikes of file operations.
    """
    def __init__(self, threshold: int = 100, window_seconds: int = 60, callback=None):
        self.threshold = int(threshold)
        self.window_seconds = int(window_seconds)
        self.callback = callback
        self.events: list[float] = []
        self.lock = threading.Lock()
        self.last_alert_time = 0.0
    def record_event(self):
        now = time.time()
        with self.lock:
            self.events.append(now)
            cutoff = now - self.window_seconds
            self.events = [t for t in self.events if t >= cutoff]
            if len(self.events) >= self.threshold:
                if now - self.last_alert_time > self.window_seconds:
                    self.last_alert_time = now
                    if self.callback:
                        try:
                            self.callback()
                        except Exception as e:
                            logging.error("[RANSOM] Callback error: %r", e)
class DebounceFilter:
    """
    Debounce filter for file events.
    Suppresses repeated events of the same (path, event_type) pair within `debounce_seconds`
    to reduce noisy duplicates from underlying OS notifications.
    """
    def __init__(self, debounce_seconds: float = 2.0):
        self.debounce_seconds = float(debounce_seconds)
        self.last_event_time: dict[tuple[str, str], float] = {}
        self.lock = threading.Lock()
    def should_emit(self, path: str, event_type: str) -> bool:
        now = time.time()
        key = (path, event_type)
        with self.lock:
            t = self.last_event_time.get(key)
            if t is None or (now - t) > self.debounce_seconds:
                self.last_event_time[key] = now
                return True
            return False
class EventProcessor(FileSystemEventHandler):
    """
    Watchdog event handler.
    Responsibilities:
    - Exclusion filtering and debouncing.
    - JSONL logging of events.
    - Notifications (Telegram, Zabbix).
    - Coalescing: delay emitting "modified" if a terminal action (delete/move) follows quickly.
    - UI buffering via a push callback.
    Also suppresses an immediate "modified" that sometimes follows right after "created".
    """
    def __init__(
        self,
        event_queue: queue.Queue,
        exclude_patterns: list[str],
        log_path: str,
        telegram_notifier: TelegramNotifier,
        zabbix_notifier: ZabbixNotifier,
        ransomware_detector: RansomwareDetector,
        debounce_filter: DebounceFilter,
        ui_push,  # callable(dict) -> None
    ):
        super().__init__()
        self.event_queue = event_queue
        self.exclude_patterns = exclude_patterns or []
        self.log_path = log_path
        self.telegram_notifier = telegram_notifier
        self.zabbix_notifier = zabbix_notifier
        self.ransomware_detector = ransomware_detector
        self.debounce_filter = debounce_filter
        self.ui_push = ui_push
        os.makedirs(os.path.dirname(self.log_path) or ".", exist_ok=True)
        self.log_lock = threading.Lock()
        # For coalescing "modified" that are followed by "deleted"/"moved".
        self.pending_modified: dict[str, dict] = {}  # path -> {"start_ts": float}
        self.pending_lock = threading.Lock()
        self.modified_delay = 0.5  # seconds; tune for your workload
        self.last_terminal_action: dict[str, float] = {}  # path -> ts of last deleted/moved
        # Suppress noisy "modified" immediately after "created".
        self.last_created_ts: dict[str, float] = {}
        self.created_modified_suppress_sec: float = 0.7  # seconds
    def _write_log(self, data: dict):
        line = json.dumps(data, ensure_ascii=False)
        with self.log_lock, open(self.log_path, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    def _is_excluded(self, path: str) -> bool:
        """
        Naive pattern-based exclusion:
        - If pattern starts with '.', treat it as a suffix (e.g., '.log' excludes paths ending with '.log').
        - Otherwise, a case-insensitive substring match.
        """
        p = (path or "").lower()
        for pattern in self.exclude_patterns:
            pat = (pattern or "").lower().strip()
            if not pat:
                continue
            if pat.startswith("."):
                if p.endswith(pat):
                    return True
            else:
                if pat in p:
                    return True
        return False
    def _emit_ui_and_tg(self, action: str, path: str, src_path: str | None = None, dest_path: str | None =None):
        """
        Build a UI event object, push it to UI and the internal queue,
        and optionally send a short Telegram message.
        """
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        if dest_path:
            desc = f"{action}: {path} → {dest_path}"
        else:
            desc = f"{action}: {path}"
        low = action.lower()
        if "created" in low:
            category = "Create"
        elif "deleted" in low:
            category = "Delete"
        elif "modified" in low or "changed" in low:
            category = "Modify"
        elif "moved" in low or "renamed" in low:
            category = "Move"
        else:
            category = action
        ev = {
            "timestamp": ts,
            "action": action,
            "category": category,
            "path": path,
            "src_path": src_path,
            "dest_path": dest_path,
            "description": desc,
        }
        self.ui_push(ev)
        try:
            self.event_queue.put_nowait(ev)
        except queue.Full:
            pass
        if self.telegram_notifier.enabled:
            # Escape minimal HTML for safe `parse_mode=HTML`.
            safe_path = (path or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            msg = (
                f"A change was detected: <b>{ts}</b> {action} <code>{safe_path}</code>.\n"
                f"Do you want to restrict the server's internet connectivity?"
            )
            self.telegram_notifier.send_message(msg)
    def _schedule_modified_emit(self, path: str):
        """
        Delay emission of 'modified' to allow possible terminal actions to arrive.
        If a delete/move for the same path is seen within the window, the 'modified' is suppressed.
        """
        def _worker(p=path):
            time.sleep(self.modified_delay)
            with self.pending_lock:
                entry = self.pending_modified.get(p)
                if not entry:
                    return
                last_term = self.last_terminal_action.get(p, 0.0)
                if last_term >= entry["start_ts"]:
                    self.pending_modified.pop(p, None)
                    return
                self.pending_modified.pop(p, None)
            self._emit_ui_and_tg("File modified", p)
        threading.Thread(target=_worker, name="emit_modified_delay", daemon=True).start()
    # === Watchdog callbacks ===
    def on_created(self, event: FileCreatedEvent):
        if self._is_excluded(event.src_path):
            return
        path = event.src_path
        if not self.debounce_filter.should_emit(path, "created"):
            return
        raw = {
            "event": "created",
            "path": path,
            "is_directory": event.is_directory,
            "time": datetime.now().isoformat(),
        }
        self._write_log(raw)
        if self.zabbix_notifier.enabled:
            self.zabbix_notifier.send_event_json(raw)
        self.ransomware_detector.record_event()
        self.last_created_ts[path] = time.monotonic()
        action = "Folder created" if event.is_directory else "File created"
        self._emit_ui_and_tg(action, path)
    def on_deleted(self, event: FileDeletedEvent):
        if self._is_excluded(event.src_path):
            return
        path = event.src_path
        if not self.debounce_filter.should_emit(path, "deleted"):
            return
        raw = {
            "event": "deleted",
            "path": path,
            "is_directory": event.is_directory,
            "time": datetime.now().isoformat(),
        }
        self._write_log(raw)
        if self.zabbix_notifier.enabled:
            self.zabbix_notifier.send_event_json(raw)
        self.ransomware_detector.record_event()
        with self.pending_lock:
            self.last_terminal_action[path] = time.time()
            self.pending_modified.pop(path, None)
        action = "Folder deleted" if event.is_directory else "File deleted"
        self._emit_ui_and_tg(action, path)
    def on_modified(self, event: FileModifiedEvent):
        if self._is_excluded(event.src_path):
            return
        path = event.src_path
        if event.is_directory:
            return
        # Suppress spurious 'modified' right after a 'created'.
        created_ts = self.last_created_ts.get(path, 0.0)
        if created_ts and (time.monotonic() - created_ts) < self.created_modified_suppress_sec:
            return
        if not self.debounce_filter.should_emit(path, "modified"):
            return
        raw = {
            "event": "modified",
            "path": path,
            "is_directory": event.is_directory,
            "time": datetime.now().isoformat(),
        }
        self._write_log(raw)
        if self.zabbix_notifier.enabled:
            self.zabbix_notifier.send_event_json(raw)
        self.ransomware_detector.record_event()
        with self.pending_lock:
            self.pending_modified[path] = {"start_ts": time.time()}
        self._schedule_modified_emit(path)
    def on_moved(self, event: FileMovedEvent):
        if self._is_excluded(event.src_path) or self._is_excluded(event.dest_path):
            return
        if not self.debounce_filter.should_emit(event.src_path, "moved"):
            return
        raw = {
            "event": "moved",
            "src_path": event.src_path,
            "dest_path": event.dest_path,
            "is_directory": event.is_directory,
            "time": datetime.now().isoformat(),
        }
        self._write_log(raw)
        if self.zabbix_notifier.enabled:
            self.zabbix_notifier.send_event_json(raw)
        self.ransomware_detector.record_event()
        with self.pending_lock:
            self.last_terminal_action[event.src_path] = time.time()
            self.pending_modified.pop(event.src_path, None)
        action = "Folder moved" if event.is_directory else "File moved"
        self._emit_ui_and_tg(action, event.src_path, src_path=event.src_path, dest_path=event.dest_path)
class WatchManager:
    """
    Orchestrates multiple observers and holds shared configuration/state.
    Features:
    - Manages exclusion patterns, JSONL log path, and notifiers (Telegram/Zabbix).
    - Maintains a ring buffer of UI events with a monotonic sequence number (`seq`)
      so the frontend can fetch deltas without missing events.
    - Persists Telegram settings (`config.json`) so they survive restarts and the
      drop-listener auto-starts next time.
    - Implements OS-specific network-disable logic triggered by a Telegram "drop" command.
    """
    def __init__(self):
        self.observers: list[Observer] = []
        self.event_queue: "queue.Queue[dict]" = queue.Queue(maxsize=5000)
        self.exclude_patterns: list[str] = []
        self.log_path = os.path.join(os.getcwd(), "events.log")
        self.telegram_notifier = TelegramNotifier(bot_token="", chat_id="")
        self.zabbix_notifier = ZabbixNotifier(server="", host="", key="")
        self.ransomware_detector = RansomwareDetector(
            threshold=100,
            window_seconds=60,
            callback=self._on_ransomware_detected,
        )
        self.debounce_filter = DebounceFilter(debounce_seconds=2.0)
        self.monitoring_active = False
        # UI buffer: newest first
        self.ui_events = deque(maxlen=5000)
        self.ui_lock = threading.Lock()
        self.seq = 0
        # Basic environment info attached to UI events.
        self.user_name = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
        self.local_ip = self._get_local_ip()
        self.drop_listener: TelegramDropListener | None = None
        self.config_path = os.path.join(os.getcwd(), "config.json")
        self._load_config()
    # === UI buffering helpers ===
    def _push_ui(self, ev: dict):
        """
        Attach common fields, assign a monotonically increasing `seq`,
        and push the event into the ring buffer (newest first).
        """
        if ev is not None:
            ev.setdefault("user_name", self.user_name)
            ev.setdefault("user_ip", self.local_ip)
        with self.ui_lock:
            self.seq += 1
            ev["seq"] = self.seq
            self.ui_events.appendleft(ev)
    def get_events(self, since_seq: Optional[int] = None, limit: int = 200) -> list[dict]:
        """
        Fetch recent UI events.
        - If `since_seq` is provided, returns only events with `seq > since_seq`.
        - Results are capped by `limit`.
        """
        with self.ui_lock:
            if since_seq is not None:
                out = [e for e in self.ui_events if e.get("seq", 0) > since_seq]
                return out[:limit]
            return list(self.ui_events)[:limit]
    # === Notifiers/config ===
    def configure_telegram(self, token: str, chat_id: str | int):
        """
        Reconfigure Telegram notifier and start the drop listener if both token and chat_id are provided.
        Persist the configuration so it survives restarts.
        """
        try:
            self.telegram_notifier.stop()
        except Exception:
            pass
        if self.drop_listener:
            try:
                self.drop_listener.stop()
            except Exception:
                pass
        self.telegram_notifier = TelegramNotifier(token, chat_id)
        if token and chat_id:
            self.drop_listener = TelegramDropListener(token, chat_id, callback=self._on_drop)
        else:
            self.drop_listener = None
        try:
            self._save_config(token, chat_id)
        except Exception:
            logging.exception("[CONFIG] Failed to save Telegram config")
    def configure_zabbix(self, server: str, host: str, key: str, port: int = 10051):
        """Reconfigure Zabbix trap sender."""
        self.zabbix_notifier = ZabbixNotifier(server, host, key, port=port)
    def set_exclude_patterns(self, patterns: list[str]):
        """Set path exclusion patterns (see `_is_excluded` for matching rules)."""
        self.exclude_patterns = patterns or []
    def set_log_path(self, path: str):
        """Set the JSONL events log file path."""
        self.log_path = path
    # === Ransomware alert ===
    def _on_ransomware_detected(self):
        """
        Called when the simple mass-change heuristic trips.
        Pushes a UI alert and notifies via Telegram/Zabbix if enabled.
        """
        alert_msg = "⚠️ Warning: massive file changes detected, possible ransomware activity!"
        ev = {
            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "action": "Warning",
            "category": "Warning",
            "path": "",
            "description": alert_msg,
            "alert": True,
        }
        self._push_ui(ev)
        try:
            self.event_queue.put_nowait(ev)
        except queue.Full:
            pass
        if self.telegram_notifier.enabled:
            self.telegram_notifier.send_message(alert_msg)
        if self.zabbix_notifier.enabled:
            # Send a JSON flag; easy to trigger Zabbix actions by matching `"alert":1`.
            self.zabbix_notifier.send_event_json({
                "alert": 1,
                "time": datetime.now().isoformat(),
                "msg": "massive file changes detected"
            })
    # === Drop command ===
    def _on_drop(self):
        """
        Invoked by TelegramDropListener when a 'drop' command is received from the configured chat.
        Attempts to disable network connectivity using OS-specific mechanisms.
        """
        try:
            print("User requested drop")
            try:
                self._disable_network()
            except Exception as e:
                logging.error("[DROP] Failed to disable network: %r", e)
        except Exception:
            pass
    # === Config persistence ===
    def _save_config(self, token: str, chat_id: str | int) -> None:
        """Persist Telegram configuration atomically into `config.json`."""
        cfg = {
            "telegram_token": (token or "").strip(),
            "telegram_chat_id": str(chat_id).strip() if chat_id is not None else "",
        }
        tmp_path = self.config_path + ".tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            json.dump(cfg, f)
        os.replace(tmp_path, self.config_path)
    def _load_config(self) -> None:
        """Load Telegram configuration (if present) and initialize notifiers/listeners."""
        if not os.path.isfile(self.config_path):
            return
        try:
            with open(self.config_path, "r", encoding="utf-8") as f:
                data = json.load(f)
            token = data.get("telegram_token") or ""
            chat = data.get("telegram_chat_id") or ""
            if token and chat:
                self.configure_telegram(token, chat)
        except Exception:
            logging.exception("[CONFIG] Failed to load config")
    # === Network disabling ===
    def _disable_network(self) -> None:
        """
        Best-effort attempt to disable network connectivity across platforms.
        Linux:
          - Prefer `nmcli networking off`.
          - Fallback: enumerate interfaces in `/sys/class/net` and `ip link set <iface> down` (excluding loopback).
        macOS:
          - Try `networksetup -listnetworkserviceorder` and disable listed services.
          - Fallback: `networksetup -setairportpower en0 off` for Wi-Fi.
        Windows:
          - Powershell: `Disable-NetAdapter` for adapters with Status 'Up'.
        All calls are fire-and-forget where possible to avoid blocking.
        """
        import platform
        system = platform.system().lower()
        if system == "linux":
            cmd = ["nmcli", "networking", "off"]
            try:
                subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                logging.info("[DROP] Executed Linux network disable via nmcli")
                return
            except FileNotFoundError:
                interfaces = []
                try:
                    interfaces = os.listdir("/sys/class/net")
                except Exception:
                    pass
                for iface in interfaces:
                    if iface == "lo":
                        continue
                    try:
                        subprocess.Popen(["ip", "link", "set", iface, "down"],
                                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                        logging.info("[DROP] Disabled interface %s via ip link", iface)
                    except Exception:
                        pass
                return
        elif system == "darwin":
            try:
                services_out = subprocess.check_output(
                    ["/usr/sbin/networksetup", "-listnetworkserviceorder"],
                    stderr=subprocess.DEVNULL
                ).decode("utf-8")
                import re
                matches = re.findall(r"\(Device: ([^)]+)\)", services_out)
                if matches:
                    for dev in matches:
                        if dev == "lo0":
                            continue
                        try:
                            subprocess.Popen(
                                ["/usr/sbin/networksetup", "-setnetworkserviceenabled", dev, "off"],
                                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
                            )
                            logging.info("[DROP] Disabled macOS network service %s", dev)
                        except Exception:
                            pass
                    return
            except Exception:
                pass
            try:
                subprocess.Popen(
                    ["/usr/sbin/networksetup", "-setairportpower", "en0", "off"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
                )
                logging.info("[DROP] Disabled macOS Wi-Fi via networksetup")
            except Exception:
                pass
        elif system == "windows":
            try:
                ps_script = (
                    "Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
                    "Disable-NetAdapter -Confirm:$false"
                )
                subprocess.Popen(
                    ["powershell", "-NoProfile", "-Command", ps_script],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
                )
                logging.info("[DROP] Executed Windows network disable via PowerShell")
            except Exception:
                pass
        else:
            logging.warning("[DROP] Unsupported OS for network disable: %s", system)
    def _get_local_ip(self) -> str:
        """Best-effort local IPv4 detection (UDP connect to 8.8.8.8; fallback to hostname resolution)."""
        ip = ""
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            ip = s.getsockname()[0]
            s.close()
        except Exception:
            try:
                ip = socket.gethostbyname(socket.gethostname())
            except Exception:
                ip = ""
        return ip
    # === Start/stop ===
    def start_monitoring(self, paths: list[str]):
        """
        Start observers for the given directories.
        If monitoring is already active, it resets state (UI buffer, queues, debounce filter,
        ransomware detector) to start fresh.
        """
        if self.monitoring_active:
            self.stop_monitoring()
            with self.ui_lock:
                self.ui_events.clear()
                self.seq = 0
            self.event_queue = queue.Queue(maxsize=5000)
            self.debounce_filter = DebounceFilter(debounce_seconds=2.0)
            self.ransomware_detector = RansomwareDetector(
                threshold=100,
                window_seconds=60,
                callback=self._on_ransomware_detected,
            )
        self.monitoring_active = True
        for path in paths:
            if not os.path.isdir(path):
                logging.warning("[WATCH] Skip non-existing dir: %s", path)
                continue
            handler = EventProcessor(
                event_queue=self.event_queue,
                exclude_patterns=self.exclude_patterns,
                log_path=self.log_path,
                telegram_notifier=self.telegram_notifier,
                zabbix_notifier=self.zabbix_notifier,
                ransomware_detector=self.ransomware_detector,
                debounce_filter=self.debounce_filter,
                ui_push=self._push_ui,
            )
            obs = Observer()
            obs.schedule(handler, path, recursive=True)
            obs.start()
            self.observers.append(obs)
            logging.info("[WATCH] Started: %s", path)
    def stop_monitoring(self):
        """
        Stop all observers and wait briefly for them to exit.
        Leaves notifiers and configuration intact.
        """
        for obs in self.observers:
            try:
                obs.stop()
            except Exception:
                pass
        for obs in self.observers:
            try:
                obs.join(timeout=2.0)
            except Exception:
                pass
        self.observers.clear()
        self.monitoring_active = False
        logging.info("[WATCH] Stopped all observers")


app.py

 

from flask import Flask, request, jsonify, send_file, render_template, send_from_directory
import os
import threading
import time
from watcher import WatchManager
# Serve static files from the default "static" directory.
# We don't use Flask templates here — the index page is returned via send_file.
app = Flask(__name__, static_folder="static")
watch_manager = WatchManager()
@app.route("/")
def index():
    """
    Return the main page.
    We load `index.html` from the project root (next to this file) instead of using
    `template_folder` to avoid errors if a `templates` directory is not present.
    """
    return send_file(os.path.join(os.path.dirname(__file__), "index.html"))
@app.route("/api/start_monitoring", methods=["POST"])
def api_start_monitoring():
    """
    Start monitoring for the given paths.
    Body (JSON):
      - paths:    list[str]  directories to watch (will be normalized to absolute paths)
      - excludes: list[str]  naive exclude patterns (substring or suffix starting with '.')
      - log_path: str        optional path to JSONL log file
    """
    data = request.get_json(force=True) or {}
    paths = data.get("paths", [])
    excludes = data.get("excludes", [])
    log_path = data.get("log_path")
    # Ensure paths are absolute
    normalized_paths = []
    for p in paths:
        if p:
            normalized_paths.append(os.path.abspath(p))
    if log_path:
        watch_manager.set_log_path(log_path)
    watch_manager.set_exclude_patterns(excludes)
    watch_manager.start_monitoring(normalized_paths)
    return jsonify({"status": "monitoring_started"})
@app.route("/api/stop_monitoring", methods=["POST"])
def api_stop_monitoring():
    """Stop all active observers."""
    watch_manager.stop_monitoring()
    return jsonify({"status": "monitoring_stopped"})
@app.route("/api/configure_telegram", methods=["POST"])
def api_configure_telegram():
    """
    Configure Telegram notifier.
    Body (JSON):
      - token:   str  bot token
      - chat_id: str|int chat id to send messages to
    """
    data = request.get_json(force=True) or {}
    token = data.get("token", "")
    chat_id = data.get("chat_id", "")
    watch_manager.configure_telegram(token, chat_id)
    return jsonify({"status": "telegram_configured"})
@app.route("/api/configure_zabbix", methods=["POST"])
def api_configure_zabbix():
    """
    Configure Zabbix trap sender.
    Body (JSON):
      - server: str  Zabbix server address
      - host:   str  Zabbix host (as defined in Zabbix)
      - key:    str  item key to send values to
    """
    data = request.get_json(force=True) or {}
    server = data.get("server", "")
    host = data.get("host", "")
    key = data.get("key", "")
    watch_manager.configure_zabbix(server, host, key)
    return jsonify({"status": "zabbix_configured"})
@app.route("/api/events", methods=["GET"])
def api_get_events():
    """
    Fetch recent UI events.
    Query params:
      - since_seq: int (optional) return events with seq > since_seq
      - limit:     int (default 200) maximum number of events to return
    """
    since = request.args.get("since_seq", type=int)  # may be None
    limit = request.args.get("limit", 200, type=int)
    events = watch_manager.get_events(since_seq=since, limit=limit)
    return jsonify({"events": events})
@app.route("/api/download_log", methods=["GET"])
def api_download_log():
    """Provide the JSONL log file for download."""
    log_path = watch_manager.log_path
    if os.path.exists(log_path):
        return send_file(log_path, as_attachment=True)
    else:
        return ("Log file not found", 404)
@app.route("/static/<path:path>")
def serve_static(path):
    """Serve files from the ./static directory."""
    return send_from_directory(app.static_folder, path)
if __name__ == "__main__":
    # Optionally pick port from the environment
    port = int(os.environ.get("PORT", 5000))
    app.run(host="127.0.0.1", port=port, threaded=True)

 

index.html

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File System Monitor</title>
    <style>
        :root {
            --color-bg-primary: #000000;
            --color-bg-secondary: #0a0a0a;
            --color-bg-card: #3d3d3d;
            --color-bg-hover: #1a1a1a;
            --color-border: #262626;
            --color-border-light: #333333;
            --color-text-primary: #ededed;
            --color-text-secondary: #a1a1a1;
            --color-text-muted: #666666;
            --color-accent-blue: #3b82f6;
            --color-accent-green: #10b981;
            --color-accent-red: #ef4444;
            --color-accent-yellow: #f59e0b;
            --color-accent-purple: #8b5cf6;
            --spacing-xs: 4px;
            --spacing-sm: 8px;
            --spacing-md: 16px;
            --spacing-lg: 24px;
            --spacing-xl: 32px;
            --radius-sm: 6px;
            --radius-md: 8px;
            --radius-lg: 12px;
            --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
            --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
        }
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu','Cantarell', sans-serif;
            background-color: var(--color-bg-primary);
            color: var(--color-text-primary);
            line-height: 1.6;
            padding: var(--spacing-lg);
        }
        header {
            background-color: var(--color-bg-card);
            border: 1px solid var(--color-border);
            border-radius: var(--radius-md);
            padding: var(--spacing-lg) var(--spacing-xl);
            margin-bottom: var(--spacing-xl);
        }
        h1 {
            font-size: 28px;
            font-weight: 600;
            letter-spacing: -0.02em;
        }
        h2 {
            font-size: 18px;
            font-weight: 600;
            margin-bottom: var(--spacing-lg);
            letter-spacing: -0.01em;
        }
        h3 {
            font-size: 14px;
            font-weight: 600;
            margin-top: var(--spacing-lg);
            margin-bottom: var(--spacing-md);
            color: var(--color-text-secondary);
            text-transform: uppercase;
            letter-spacing: 0.05em;
        }
        section {
            background-color: var(--color-bg-card);
            border: 1px solid var(--color-border);
            border-radius: var(--radius-md);
            padding: var(--spacing-xl);
            margin-bottom: var(--spacing-lg);
        }
        input[type="text"] {
            width: 100%;
            padding: var(--spacing-sm) var(--spacing-md);
            background-color: var(--color-bg-secondary);
            border: 1px solid var(--color-border);
            border-radius: var(--radius-sm);
            color: var(--color-text-primary);
            font-size: 14px;
            transition: all 0.2s ease;
        }
        input[type="text"]:focus {
            outline: none;
            border-color: var(--color-accent-blue);
            background-color: var(--color-bg-primary);
        }
        input[type="text"]::placeholder {
            color: var(--color-text-muted);
        }
        button {
            padding: var(--spacing-sm) var(--spacing-md);
            background-color: var(--color-bg-secondary);
            color: var(--color-text-primary);
            border: 1px solid var(--color-border);
            border-radius: var(--radius-sm);
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.2s ease;
        }
        button:hover {
            background-color: var(--color-bg-hover);
            border-color: var(--color-border-light);
        }
        button:active {
            transform: translateY(1px);
        }
        #monitoring-settings button[onclick*="start"],
        #integrations button {
            background-color: var(--color-accent-blue);
            border-color: var(--color-accent-blue);
            color: white;
        }
        #monitoring-settings button[onclick*="start"]:hover,
        #integrations button:hover {
            background-color: #2563eb;
            border-color: #2563eb;
        }
        .path-input {
            display: flex;
            gap: var(--spacing-sm);
            margin-bottom: var(--spacing-sm);
        }
        .path-input input {
            flex: 1;
        }
        .path-input button {
            width: 36px;
            padding: 0;
            background-color: var(--color-bg-secondary);
            color: var(--color-text-secondary);
        }
        .path-input button:hover {
            background-color: var(--color-accent-red);
            border-color: var(--color-accent-red);
            color: white;
        }
        .paths {
            margin-bottom: var(--spacing-md);
        }
        .mt-10 {
            margin-top: var(--spacing-md);
        }
        .mb-10 {
            margin-bottom: var(--spacing-md);
        }
        .w-full {
            width: 100%;
        }
        label {
            display: block;
            font-size: 13px;
            font-weight: 500;
            color: var(--color-text-secondary);
            margin-bottom: var(--spacing-xs);
        }
        .filter-controls {
            display: flex;
            flex-wrap: wrap;
            gap: var(--spacing-md);
            align-items: center;
            margin-bottom: var(--spacing-md);
            padding: var(--spacing-md);
            background-color: var(--color-bg-secondary);
            border-radius: var(--radius-sm);
        }
        .filter-controls label {
            display: inline-flex;
            align-items: center;
            gap: var(--spacing-xs);
            margin: 0;
            cursor: pointer;
            font-size: 14px;
            color: var(--color-text-primary);
        }
        input[type="checkbox"] {
            width: 16px;
            height: 16px;
            cursor: pointer;
            accent-color: var(--color-accent-blue);
        }
        .action-buttons {
            display: flex;
            gap: var(--spacing-sm);
            flex-wrap: wrap;
        }
        table {
            width: 100%;
            border-collapse: separate;
            border-spacing: 0;
            font-size: 13px;
        }
        thead {
            position: sticky;
            top: 0;
            z-index: 10;
        }
        th {
            background-color: var(--color-bg-secondary);
            color: var(--color-text-secondary);
            font-weight: 600;
            text-align: left;
            padding: var(--spacing-md);
            border-bottom: 1px solid var(--color-border);
            cursor: pointer;
            user-select: none;
            transition: background-color 0.2s ease;
            white-space: nowrap;
            font-size: 12px;
            text-transform: uppercase;
            letter-spacing: 0.05em;
        }
        th:hover {
            background-color: var(--color-bg-hover);
        }
        th:first-child {
            border-top-left-radius: var(--radius-sm);
        }
        th:last-child {
            border-top-right-radius: var(--radius-sm);
        }
        td {
            padding: var(--spacing-md);
            border-bottom: 1px solid var(--color-border);
            color: var(--color-text-primary);
        }
        tbody tr {
            transition: background-color 0.15s ease;
        }
        tbody tr:hover {
            background-color: var(--color-bg-hover);
        }
        th.sorted-asc::after,
        th.sorted-desc::after {
            margin-left: var(--spacing-xs);
            color: var(--color-accent-blue);
            font-size: 10px;
        }
        th.sorted-asc::after {
            content: " ▲";
        }
        th.sorted-desc::after {
            content: " ▼";
        }
        .alert {
            color: var(--color-accent-red);
            font-weight: 600;
        }
        #json-view {
            display: none;
            white-space: pre-wrap;
            max-height: 600px;
            overflow-y: auto;
            background-color: var(--color-bg-secondary);
            padding: var(--spacing-lg);
            border: 1px solid var(--color-border);
            border-radius: var(--radius-sm);
            font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
            font-size: 12px;
            line-height: 1.5;
            color: var(--color-text-secondary);
        }
        td:nth-child(4)::before {
            content: "●";
            margin-right: var(--spacing-xs);
            font-size: 10px;
        }
        tr:has(td:nth-child(4):contains("Create")) td:nth-child(4)::before {
            color: var(--color-accent-green);
        }
        tr:has(td:nth-child(4):contains("Delete")) td:nth-child(4)::before {
            color: var(--color-accent-red);
        }
        tr:has(td:nth-child(4):contains("Modify")) td:nth-child(4)::before {
            color: var(--color-accent-blue);
        }
        tr:has(td:nth-child(4):contains("Move")) td:nth-child(4)::before {
            color: var(--color-accent-yellow);
        }
        tr:has(td:nth-child(4):contains("Warning")) td:nth-child(4)::before {
            color: var(--color-accent-purple);
        }
        ::-webkit-scrollbar {
            width: 8px;
            height: 8px;
        }
        ::-webkit-scrollbar-track {
            background: var(--color-bg-secondary);
        }
        ::-webkit-scrollbar-thumb {
            background: var(--color-border-light);
            border-radius: 4px;
        }
        ::-webkit-scrollbar-thumb:hover {
            background: var(--color-text-muted);
        }
        @media (max-width: 768px) {
            body {
                padding: var(--spacing-md);
            }
            section {
                padding: var(--spacing-lg);
            }
            table {
                font-size: 12px;
            }
            th, td {
                padding: var(--spacing-sm);
            }
        }
    </style>
</head>
<body>
    <header>
        <h1>File System Monitor</h1>
    </header>
    <section id="monitoring-settings">
        <h2>Monitoring Settings</h2>
        <div class="paths" id="paths-container">
            <div class="path-input">
                <input type="text" placeholder="Enter folder path" class="path-field">
                <button type="button" class="remove-path" onclick="removePath(this)">–</button>
            </div>
        </div>
        <button type="button" onclick="addPath()">Add path</button>
        <div class="mt-10">
            <label>Exclude files/formats (comma-separated, e.g. .log,.tmp):</label>
            <input type="text" id="exclude-patterns" class="w-full" placeholder="E.g.: .log,.json,temp">
        </div>
        <div class="mt-10 action-buttons">
            <button type="button" onclick="startMonitoring()">Start monitoring</button>
            <button type="button" onclick="stopMonitoring()">Stop monitoring</button>
        </div>
    </section>
    <section id="integrations">
        <h2>Integrations</h2>
        <h3>Telegram</h3>
        <label>Bot Token:</label>
        <input type="text" id="tg-token" class="w-full" placeholder="Enter Telegram bot token">
        <label style="margin-top: 12px;">Chat ID:</label>
        <input type="text" id="tg-chat" class="w-full" placeholder="Enter chat ID">
        <div style="margin-top: 16px;">
            <button type="button" onclick="configureTelegram()">Configure Telegram</button>
        </div>
        
        <h3>Zabbix</h3>
        <label>Server:</label>
        <input type="text" id="zbx-server" class="w-full" placeholder="Zabbix server address">
        <label style="margin-top: 12px;">Host:</label>
        <input type="text" id="zbx-host" class="w-full" placeholder="Host name">
        <label style="margin-top: 12px;">Key:</label>
        <input type="text" id="zbx-key" class="w-full" placeholder="Data key">
        <div style="margin-top: 16px;">
            <button type="button" onclick="configureZabbix()">Configure Zabbix</button>
        </div>
   </section>
    <section id="event-log">
        <h2>Event Log</h2>
        <div class="filter-controls">
            <label><input type="checkbox" class="filter" value="Create" checked> Create</label>
            <label><input type="checkbox" class="filter" value="Delete" checked> Delete</label>
            <label><input type="checkbox" class="filter" value="Modify" checked> Modify</label>
            <label><input type="checkbox" class="filter" value="Move" checked> Move</label>
            <label><input type="checkbox" class="filter" value="Warning" checked> Warnings</label>
        </div>
        <div class="mb-10" style="display: flex; gap: 8px; flex-wrap: wrap;">
            <input type="text" id="search-path" placeholder="Filter by path" style="flex: 1; min-width: 200px;">
            <div class="action-buttons">
                <button type="button" onclick="downloadLog()">Download log</button>
                <button type="button" onclick="toggleJSON()" id="toggle-json-btn">Show JSON</button>
            </div>
        </div>
        <div style="overflow-x: auto;">
            <table id="events-table">
                <thead>
                    <tr>
                        <th data-col="seq" onclick="sortTable('seq')">No</th>
                        <th data-col="timestamp" onclick="sortTable('timestamp')">Date/Time</th>
                        <th>Interval</th>
                        <th data-col="action" onclick="sortTable('action')">Event</th>
                        <th data-col="path" onclick="sortTable('path')">Source</th>
                        <th data-col="dest_path" onclick="sortTable('dest_path')">Destination</th>
                        <th data-col="path_length" onclick="sortTable('path_length')">Path length</th>
                        <th data-col="user_name" onclick="sortTable('user_name')">User</th>
                        <th data-col="user_ip" onclick="sortTable('user_ip')">IP</th>
                    </tr>
                </thead>
                <tbody id="events-body">
                </tbody>
            </table>
        </div>
        <div id="json-view"></div>
    </section>
    <script>
    let eventsBuffer = [];
    let showJSON = false;
    let lastSeq = -1;
    let currentSortColumn = null;
    let currentSortAsc = true;
    const POLL_INTERVAL_MS = 5000;
    let polling = false;
    let pollErrorBackoff = 0;
    function addPath() {
        const container = document.getElementById('paths-container');
        const div = document.createElement('div');
        div.className = 'path-input';
        div.innerHTML = `<input type="text" placeholder="Enter folder path" class="path-field"><button type="button" class="remove-path" onclick="removePath(this)">–</button>`;
        container.appendChild(div);
    }
    function removePath(btn) {
        const div = btn.parentElement;
        div.remove();
    }
    function startMonitoring() {
        const pathFields = document.querySelectorAll('.path-field');
        const paths = [];
        pathFields.forEach(field => {
            const value = field.value.trim();
            if (value) paths.push(value);
        });
        const excludes = document.getElementById('exclude-patterns').value.split(',').map(p =>p.trim()).filter(p => p);
        fetch('/api/start_monitoring', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ paths: paths, excludes: excludes })
        }).then(resp => resp.json()).then(() => {
            lastSeq = -1;
            eventsBuffer = [];
            currentSortColumn = null;
            currentSortAsc = true;
            clearSortIndicators();
            redrawAll();
            startPolling();
        });
    }
    function stopMonitoring() {
        fetch('/api/stop_monitoring', { method: 'POST' })
        .then(resp => resp.json())
        .then(() => {
            stopPolling();
            console.log('Monitoring stopped');
        });
    }
    function configureTelegram() {
        const token = document.getElementById('tg-token').value.trim();
        const chat = document.getElementById('tg-chat').value.trim();
        fetch('/api/configure_telegram', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ token: token, chat_id: chat })
        }).then(resp => resp.json()).then(() => {
            alert('Telegram configured');
            try {
                localStorage.setItem('tg_token', token);
                localStorage.setItem('tg_chat', chat);
            } catch (e) {}
        });
    }
    function configureZabbix() {
        const server = document.getElementById('zbx-server').value.trim();
        const host = document.getElementById('zbx-host').value.trim();
        const key = document.getElementById('zbx-key').value.trim();
        fetch('/api/configure_zabbix', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ server: server, host: host, key: key })
        }).then(resp => resp.json()).then(() => {
            alert('Zabbix configured');
        });
    }
    function downloadLog() {
        window.location.href = '/api/download_log';
    }
    function toggleJSON() {
        showJSON = !showJSON;
        const jsonView = document.getElementById('json-view');
        const table = document.getElementById('events-table');
        const btn = document.getElementById('toggle-json-btn');
        if (showJSON) {
            table.style.display = 'none';
            jsonView.style.display = 'block';
            btn.textContent = 'Hide JSON';
            jsonView.textContent = JSON.stringify(eventsBuffer.slice().reverse(), null, 2);
        } else {
            table.style.display = 'table';
            jsonView.style.display = 'none';
            btn.textContent = 'Show JSON';
        }
    }
    async function fetchEventsOnce() {
        const url = lastSeq >= 0 ? `/api/events?since_seq=${lastSeq}` : '/api/events';
        const resp = await fetch(url);
        const data = await resp.json();
        const arr = Array.isArray(data?.events) ? data.events : (Array.isArray(data) ? data : []);
        if (!arr || !arr.length) return;
        let maxSeq = lastSeq;
        arr.forEach(ev => {
            eventsBuffer.push(ev);
            if (typeof ev.seq === 'number' && ev.seq > maxSeq) {
                maxSeq = ev.seq;
            }
        });
        lastSeq = maxSeq;
        redrawAll();
    }
    function startPolling() {
        if (polling) return;
        polling = true;
        pollErrorBackoff = 0;
        (async function loop() {
            try {
                await fetchEventsOnce();
                pollErrorBackoff = 0;
            } catch (e) {
                pollErrorBackoff = Math.min((pollErrorBackoff || 1000) * 2, 8000);
            } finally {
                if (polling) {
                    const delay = POLL_INTERVAL_MS + (pollErrorBackoff || 0);
                    setTimeout(loop, delay);
                }
            }
        })();
    }
    function stopPolling() {
        polling = false;
    }
    function sortTable(column) {
        if (currentSortColumn === column) {
            currentSortAsc = !currentSortAsc;
        } else {
            currentSortColumn = column;
            currentSortAsc = true;
        }
        setSortIndicator(column, currentSortAsc);
        redrawAll();
    }
    function ipToNumber(ip) {
        if (!ip) return 0;
        const parts = ip.split('.').map(n => parseInt(n, 10) || 0);
        return ((parts[0] << 24) >>> 0) + ((parts[1] << 16) >>> 0) + ((parts[2] << 8) >>> 0) + (parts[3]>>> 0);
    }
    function parseTimestamp(ts) {
        if (!ts) return 0;
        const parsed = Date.parse(ts.replace(' ', 'T'));
        return isNaN(parsed) ? 0 : parsed;
    }
    function getSortValue(ev, col) {
        switch (col) {
            case 'seq': return typeof ev.seq === 'number' ? ev.seq : 0;
            case 'timestamp': return parseTimestamp(ev.timestamp || '');
            case 'action': return (ev.action || ev.category || '').toLowerCase();
            case 'path': return (ev.path || '').toLowerCase();
            case 'dest_path': return (ev.dest_path || '').toLowerCase();
            case 'path_length': {
                const p = ev.dest_path && ev.dest_path.length ? ev.dest_path : (ev.path || '');
                return (p || '').length;
            }
            case 'user_name': return (ev.user_name || '').toLowerCase();
            case 'user_ip': return ipToNumber(ev.user_ip || '');
            default: return 0;
        }
    }
    function compareEvents(a, b) {
        if (!currentSortColumn) return 0;
        const va = getSortValue(a, currentSortColumn);
        const vb = getSortValue(b, currentSortColumn);
        if (typeof va === 'number' && typeof vb === 'number') {
            if (va < vb) return currentSortAsc ? -1 : 1;
            if (va > vb) return currentSortAsc ? 1 : -1;
            return 0;
        } else {
            const sa = String(va || '');
            const sb = String(vb || '');
            const cmp = sa.localeCompare(sb, undefined, { numeric: true, sensitivity: 'base' });
            return currentSortAsc ? cmp : -cmp;
        }
    }
    function clearSortIndicators() {
        document.querySelectorAll('th[data-col]').forEach(th => {
            th.classList.remove('sorted-asc', 'sorted-desc');
        });
    }
    function setSortIndicator(column, asc) {
        clearSortIndicators();
        const th = document.querySelector(`th[data-col="${column}"]`);
        if (th) th.classList.add(asc ? 'sorted-asc' : 'sorted-desc');
    }
    function redrawAll() {
        const tbody = document.getElementById('events-body');
        tbody.innerHTML = '';
        const filters = Array.from(document.querySelectorAll('.filter:checked')).map(cb => cb.value);
        const search = document.getElementById('search-path').value.toLowerCase();
        const filtered = eventsBuffer.filter(ev => {
            const category = ev.category || ev.action || '';
            if (!filters.includes(category)) return false;
            const combinedPath = ((ev.path || '') + ' ' + (ev.dest_path || '')).toLowerCase();
            if (search && combinedPath.indexOf(search) === -1) return false;
            return true;
        });
        const displayList = filtered.slice();
        if (currentSortColumn) {
            displayList.sort(compareEvents);
        }
        let lastShownTimestamp = null;
        let rowNumber = 0;
        displayList.forEach(ev => {
            rowNumber++;
            const tr = document.createElement('tr');
            let interval = '';
            if (lastShownTimestamp) {
                const prevDate = new Date(lastShownTimestamp.replace(' ', 'T'));
                const currentDate = new Date((ev.timestamp || '').replace(' ', 'T'));
                if (!isNaN(prevDate) && !isNaN(currentDate)) {
                    const diff = (currentDate - prevDate) / 1000;
                    interval = diff.toFixed(1) + ' s';
                }
            }
            lastShownTimestamp = ev.timestamp;
            const pathForLen = ev.dest_path && ev.dest_path.length ? ev.dest_path : (ev.path || '');
            const pathLen = (pathForLen || '').length;
            const cells = [
                rowNumber,
                ev.timestamp || '',
                interval,
                ev.action || '',
                ev.path || '',
                ev.dest_path || '',
                pathLen,
                ev.user_name || '',
                ev.user_ip || ''
            ];
            cells.forEach(c => {
                const td = document.createElement('td');
                td.textContent = c;
                if (ev.alert) td.classList.add('alert');
                tr.appendChild(td);
            });
            tbody.appendChild(tr);
        });
        if (showJSON) {
            const jsonView = document.getElementById('json-view');
            jsonView.textContent = JSON.stringify(eventsBuffer.slice().reverse(), null, 2);
        }
    }
    document.addEventListener('DOMContentLoaded', function () {
        startPolling();
        document.querySelectorAll('.filter').forEach(cb => {
            cb.addEventListener('change', () => {
                redrawAll();
            });
        });
        const searchInput = document.getElementById('search-path');
        if (searchInput) {
            searchInput.addEventListener('input', () => {
                redrawAll();
            });
        }
        try {
            const storedToken = localStorage.getItem('tg_token');
            const storedChat = localStorage.getItem('tg_chat');
            if (storedToken) document.getElementById('tg-token').value = storedToken;
            if (storedChat) document.getElementById('tg-chat').value = storedChat;
        } catch (e) {}
        if (currentSortColumn) setSortIndicator(currentSortColumn, currentSortAsc);
    });
    function saveLocalData(key, value) {
        try {
            localStorage.setItem(key, JSON.stringify(value));
        } catch (e) {}
    }
    function loadLocalData(key, fallback) {
        try {
            const val = JSON.parse(localStorage.getItem(key));
            return val !== null ? val : fallback;
        } catch (e) {
            return fallback;
        }
    }
    function persistZabbixInputs() {
        const server = document.getElementById('zbx-server').value.trim();
        const host = document.getElementById('zbx-host').value.trim();
        const key = document.getElementById('zbx-key').value.trim();
        saveLocalData('zabbix_config', { server, host, key });
    }
    function persistPaths() {
        const paths = Array.from(document.querySelectorAll('.path-field'))
            .map(i => i.value.trim())
            .filter(Boolean);
        const excludes = document.getElementById('exclude-patterns').value;
        saveLocalData('watch_paths', { paths, excludes });
    }
    function restoreInputs() {
        const zbx = loadLocalData('zabbix_config', {});
        if (zbx.server) document.getElementById('zbx-server').value = zbx.server;
        if (zbx.host) document.getElementById('zbx-host').value = zbx.host;
        if (zbx.key) document.getElementById('zbx-key').value = zbx.key;
        const wp = loadLocalData('watch_paths', {});
        if (wp.paths && Array.isArray(wp.paths)) {
            const container = document.getElementById('paths-container');
            container.innerHTML = '';
            wp.paths.forEach(p => {
                const div = document.createElement('div');
                div.className = 'path-input';
                div.innerHTML = `<input type="text" value="${p}" class="path-field">
                                <button type="button" class="remove-path" onclick="removePath(this)">–</button>`;
                container.appendChild(div);
            });
        }
        if (wp.excludes) document.getElementById('exclude-patterns').value = wp.excludes;
    }
    document.addEventListener('input', function (e) {
        const id = e.target.id;
        if (id === 'zbx-server' || id === 'zbx-host' || id === 'zbx-key') {
            persistZabbixInputs();
        } else if (e.target.classList.contains('path-field') || id === 'exclude-patterns') {
            persistPaths();
        }
    });
    document.addEventListener('DOMContentLoaded', function () {
        restoreInputs();
    });
    </script>
</body>
</html>