П/ВИН

Фикс дашборда TG Army: 9 багов за один день

·9 мин чтения

Бывают дни, когда открываешь проект и понимаешь — дашборд врёт. Аккаунты в статусе frozen выглядят так же, как активные. Кнопка сохранения молча глотает изменения и ничего не пишет в базу. Вкладка Settings не переключается вообще. Именно с таким набором из 9 взаимосвязанных проблем я столкнулся в проекте tg-army — и за одну рабочую сессию разобрал их до основания.

Эта статья — честный разбор того, что сломалось, почему сломалось, и как именно это чинилось. Без купюр и ретуши.

Контекст: что такое tg-army

tg-army — внутренний инструмент автоматизации Telegram-аккаунтов. Система управляет пулом аккаунтов, прогоняет их через воронку диалогов, следит за статусами (активный, заморожен, в прогреве, удалён), запускает AI-диалоги и собирает статистику. Веб-дашборд — единственный интерфейс для оператора: здесь видно состояние аккаунтов, редактируются шаблоны сообщений, настраивается воронка.

Проект рос быстро, и в какой-то момент накопился технический долг: база данных переехала с SQLite на PostgreSQL, а веб-часть об этом не знала.

Проблема первая: CSS-статусы которых не существовало

Первое, что бросилось в глаза — в таблице аккаунтов все статусы отображались одинаково. Аккаунт frozen визуально ничем не отличался от active. Причина банальная: в base.html были прописаны только стили для badge-active, badge-expired и badge-banned, а три новых статуса (frozen, warming, deleted) добавили в логику позже, но про CSS забыли.

До:

.badge-active  { background: #28a745; color: #fff; }
.badge-expired { background: #6c757d; color: #fff; }
.badge-banned  { background: #dc3545; color: #fff; }

После:

.badge-active  { background: #28a745; color: #fff; }
.badge-expired { background: #6c757d; color: #fff; }
.badge-banned  { background: #dc3545; color: #fff; }
.badge-frozen  { background: #17a2b8; color: #fff; }
.badge-warming { background: #fd7e14; color: #fff; }
.badge-deleted { background: #343a40; color: #adb5bd; border: 1px solid #6c757d; }
 
tr.dead-account {
  opacity: 0.5;
  pointer-events: none;
}

Цветовая логика простая: замороженный — холодный голубой, прогрев — оранжевый (тепло), удалённый — тёмный и полупрозрачный. Мёртвые аккаунты (deleted, banned) дополнительно получили класс dead-account на строку таблицы — opacity: 0.5 визуально отодвигает их на второй план, не убирая из списка.

Параллельно обновили status_colors в app.py и расширили valid_statuses в API-эндпоинте с 4 до 7 значений. Это важно: без этого PATCH-запрос на смену статуса возвращал 400, даже если CSS уже был готов.

Проблема вторая: вкладки Settings не переключались

Пользователь заходит в Settings, кликает на вкладку «AI Промт» — ничего не происходит. Вкладка Bootstrap не реагирует на клик.

Первая гипотеза — сломан HTML (например, промт-текст содержит кавычки, которые ломают атрибуты). Проверил — нет, HTML валиден.

Вторая гипотеза — Bootstrap JS не загружается. Открыл DevTools в голове, вспомнил контекст: сервер в России, Bootstrap подключён через cdn.jsdelivr.net с integrity хешем:

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-..."
        crossorigin="anonymous"></script>

jsdelivr.net периодически недоступен из российских сетей. Если CDN не отвечает — браузер просто не получает скрипт, и все Bootstrap-компоненты молчат: не только табы, но и dropdown-ы, тосты, модалки.

Решение в два шага:

Шаг 1 — добавил vanilla JS fallback прямо в шаблон settings.html, который не зависит ни от каких CDN:

document.addEventListener('DOMContentLoaded', function() {
  // Fallback для Bootstrap tabs если BS JS не загрузился
  var tabLinks = document.querySelectorAll('[data-bs-toggle="tab"]');
  if (tabLinks.length && typeof bootstrap === 'undefined') {
    tabLinks.forEach(function(link) {
      link.addEventListener('click', function(e) {
        e.preventDefault();
        var target = document.querySelector(this.getAttribute('data-bs-target'));
        document.querySelectorAll('.tab-pane').forEach(function(p) {
          p.classList.remove('show', 'active');
        });
        document.querySelectorAll('.nav-link').forEach(function(l) {
          l.classList.remove('active');
        });
        if (target) target.classList.add('show', 'active');
        this.classList.add('active');
      });
    });
  }
});

Шаг 2 — скачал Bootstrap CSS и JS локально в src/web/static/ и переключил base.html на локальную раздачу через Flask static files:

<!-- Было -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" ...>
 
<!-- Стало -->
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>

Теперь интерфейс работает независимо от доступности CDN. Это важный урок для любого проекта, который хостится в России или обслуживает российскую аудиторию: никогда не полагайся на зарубежные CDN как на единственный источник критических скриптов.

Проблема третья: сохранение не работало (SQLite vs PostgreSQL)

Самая коварная из девяти проблем. Пользователь редактирует шаблон, нажимает «Сохранить», видит зелёный тост «Сохранено» — но при следующем открытии страницы изменений нет. Данные не сохранялись.

Копаю в app.py — функция _get_db() выглядит вот так:

def _get_db():
    return Database('data/accounts.db')  # SQLite!

А весь остальной проект уже давно работает с PostgreSQL через PgDatabase. Worker читает аккаунты из Postgres, orchestrator пишет статистику в Postgres — а веб-панель читает и пишет в пустой SQLite-файл. Классический случай, когда миграция базы данных не была применена ко всем компонентам системы.

Переключение веб-панели на PostgreSQL потребовало нескольких правок:

  1. Изменить _get_db() чтобы возвращал PgDatabase вместо Database
  2. Исправить все raw SQL-запросы с плейсхолдером ? (стиль SQLite) на $1, $2, ... (стиль asyncpg)
  3. Убрать await db.close() в finally-блоках — shared connection pool нельзя закрывать на каждый запрос

Пример исправления SQL:

# Было (SQLite)
result = await db.fetchone(
    'SELECT * FROM accounts WHERE id = ?', 
    (account_id,)
)
 
# Стало (asyncpg)
result = await db.fetchone(
    'SELECT * FROM accounts WHERE id = $1', 
    account_id
)

После переключения вылезла следующая проблема: шаблоны Jinja2 падали с ошибкой при попытке сделать created_at[:16]. SQLite возвращал дату как строку "2024-01-15 10:30:00", и slice [:16] давал "2024-01-15 10:30". PostgreSQL через asyncpg возвращает Python-объект datetime — и slice на объекте datetime выбрасывает TypeError.

Поправил в шаблонах:

{# Было #}
{{ account.created_at[:16] }}
 
{# Стало #}
{{ account.created_at.strftime('%Y-%m-%d %H:%M') if account.created_at else '' }}

Аналогичная история с fromisoformat() в app.py — добавил проверку типа перед вызовом:

# Было
created = datetime.fromisoformat(account['created_at'])
 
# Стало
raw = account['created_at']
created = raw if isinstance(raw, datetime) else datetime.fromisoformat(raw)

Проблема четвёртая: отправка кейсов одним сообщением

Отдельная задача в той же сессии — шаг 5 воронки должен отправлять несколько скриншотов результатов клиентов как один альбом (media group), а не 4 отдельных сообщения. Это важно для UX получателя: альбом выглядит компактно, а 4 отдельных сообщения подряд — как спам.

Telethon поддерживает отправку медиагруппы через client.send_file() со списком файлов:

# Было: 4 отдельных сообщения
for img_path in case_files:
    await client.send_file(recipient, img_path)
 
# Стало: один альбом
case_files = sorted(glob.glob('assets/cases/*.png'))
if case_files:
    await client.send_file(
        recipient,
        case_files,          # список → media group
        caption=step_text    # подпись только к первому фото
    )

Фотографии (4 PNG с кейсами) скопировали в assets/cases/, обновили текст шаблона шага 5 в базе и в дефолтах compose.py.

Большой рефакторинг: multi-worker архитектура

Параллельно с фиксами шёл более масштабный проект — переход на multi-worker архитектуру с Redis в качестве брокера задач. План документирован в docs/plans/2026-03-03-multi-worker-phase0-phase1-plan.md, 10 задач, ~3100 строк нового кода, 19 файлов.

Phase 0 (задачи 1-5):

  • Task 1: Redis в Docker на порту 6380 (6379 был занят lobe-redis)
  • Task 2: PostgreSQL-схема tg_army — 15 таблиц, индексы, grants
  • Task 3: Миграция данных SQLite → PostgreSQL
  • Task 4: PgDatabase — полная реплика API Database, 47+ методов, asyncpg под капотом
  • Task 5: Обновление зависимостей и config.py

Миграция данных прошла без потерь: 12 таблиц, row counts совпали. PgDatabase покрыт 50 тестами, RedisClient — 7 тестами. Итого по итогам Phase 0: 57 тестов, 0 провалов.

Код ревью после Batch 2 выявил несколько замечаний, которые были исправлены до мержа в main.

Тесты: 37 → 57 за сессию

До начала работ проект имел 37 тестов. После всех изменений — 57. Новые тесты покрывают:

  • CSS-классы для новых статусов (snapshot-тест рендера badge)
  • get_dashboard_stats() — новый метод агрегации статистики
  • Все 7 статусов в dropdown фильтра
  • PgDatabase — полный API (47 методов)
  • RedisClient — 7 методов очереди задач
$ pytest tests/ -v
...
57 passed in 4.83s

Что пошло не так и как это предотвратить

Оглядываясь назад, выделю три системных проблемы, которые привели к накоплению этих 9 багов.

Проблема 1: миграция базы данных без обновления всех потребителей. Когда проект переехал с SQLite на PostgreSQL, worker и orchestrator обновили, а веб-панель — нет. Это классическая ошибка при поэтапной миграции. Решение: иметь единый _get_db() в одном месте, который все импортируют. Если меняешь реализацию — меняешь в одном месте.

Проблема 2: внешние CDN как единственный источник критических зависимостей. Bootstrap с jsdelivr работал, пока работал. CDN — это чужой сервер, и он может быть недоступен по сотне причин: блокировки, DDoS, аварии. Для production-проектов — всегда держи копию критических front-end библиотек локально. Или используй self-hosted CDN.

Проблема 3: фронтенд не синхронизирован с бэкендом. Новые статусы появились в логике Python, но не в CSS и не в API-валидации. Это говорит об отсутствии единой точки правды для перечня статусов. Правильное решение — хранить список допустимых статусов в одном месте (например, как Python Enum) и генерировать из него и CSS-классы, и API-валидацию, и dropdown-опции.

Итоги и уроки

За одну рабочую сессию: исправлено 9 багов, добавлено 20 новых тестов, проект переведён на PostgreSQL в веб-части, Bootstrap вынесен локально, отправка кейсов переделана в media group.

Главный урок этой сессии — технический долг не ждёт удобного момента. Каждый раз когда ты говоришь «потом поправим», ты создаёшь будущую аварию. Дашборд который показывает неправильные данные — это не просто UX-проблема. Это потеря доверия оператора к системе, а значит ошибки в управлении аккаунтами.

Второй урок: при любой миграции инфраструктуры (база данных, очередь, CDN) — составляй чеклист всех компонентов которые используют старую инфраструктуру. Не только worker и orchestrator, но и веб-панель, скрипты миграции, тесты, CI/CD. Каждый из них — потенциальная точка отказа.

Третий урок: локальный Bootstrap — это не паранойя, это гигиена. Если твой интерфейс обслуживает пользователей в регионах с нестабильным доступом к зарубежным CDN, внешние зависимости превращаются в бомбу замедленного действия. Три команды wget экономят часы дебаггинга.

Четвёртый урок: типы данных на границе ORM — источник скрытых багов. SQLite возвращает строки, asyncpg возвращает Python-объекты. Когда меняешь базу данных — обязательно проверяй все места где код делает предположения о типах (срезы строк, fromisoformat, сравнения). Лучше — покрой эти места тестами заранее, чтобы миграция сломала тесты, а не прод.

Ссылки

Паша Вин
Паша Вин

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →