П/ВИН

Как мы починили TG Army Dashboard: 9 багов за 3 сессии

·9 мин чтения
Как мы починили TG Army Dashboard: 9 багов за 3 сессии

Когда работаешь над инструментом автоматизации Telegram-аккаунтов, последнее, с чем хочется возиться — это интерфейс. Но иногда интерфейс превращается в узкое место, которое блокирует всю команду. Именно это случилось с TG Army Dashboard: накопилось 9 проблем, которые делали панель управления практически непригодной для ежедневной работы. В этой статье разберу, как мы системно их починили — от косметических CSS-правок до полноценной миграции на PostgreSQL и Redis.

Контекст: что такое TG Army

TG Army — внутренний инструмент для управления пулом Telegram-аккаунтов. Аккаунты проходят через воронку прогрева: от нового аккаунта до активного, способного рассылать сообщения по целевой аудитории. У каждого аккаунта есть статус — active, frozen, warming, expired, deleted, banned, dead. Worker-процессы управляют жизненным циклом аккаунтов, а Flask-дашборд даёт оператору возможность видеть состояние системы и управлять настройками.

Проблема в том, что дашборд создавался итеративно — сначала было три статуса, потом четыре, потом семь. И на каком-то этапе визуальный слой перестал успевать за бизнес-логикой.

9 проблем, которые мы решали

Перед стартом зафиксировали полный список:

  1. Отсутствуют CSS-стили для frozen, warming, deleted
  2. API не принимает эти статусы при смене через dropdown
  3. Dropdown в интерфейсе содержит только старые статусы
  4. Нет статистики по AI-диалогам на главном экране
  5. Нет статистики по прогреву аккаунтов
  6. Мёртвые аккаунты (dead, expired, banned) не отделяются визуально
  7. Вкладки на странице Settings не переключаются
  8. Изменения шаблонов воронки не сохраняются
  9. Dashboard показывает данные из SQLite вместо PostgreSQL

Последние два пункта — самые коварные. Они выглядят как баги UI, а на самом деле это архитектурная проблема.

Шаг 1: CSS и API — быстрые победы

Начали с самого видимого — значки статусов. Каждый статус в таблице аккаунтов отображается как badge, и для новых статусов просто не было стилей.

До:

<!-- base.html — только 3 стиля -->
<style>
  .badge-active { background: #28a745; color: white; }
  .badge-expired { background: #6c757d; color: white; }
  .badge-banned { background: #dc3545; color: white; }
</style>

После:

<!-- base.html — 7 статусов + dead-account row -->
<style>
  .badge-active   { background: #28a745; color: white; }
  .badge-frozen   { background: #17a2b8; color: white; }
  .badge-warming  { background: #fd7e14; color: white; }
  .badge-expired  { background: #6c757d; color: white; }
  .badge-deleted  { background: #343a40; color: #adb5bd; border: 1px solid #495057; }
  .badge-banned   { background: #dc3545; color: white; }
  .badge-dead     { background: #212529; color: #6c757d; }
  tr.dead-account { opacity: 0.5; }
</style>

Цветовая логика простая: активные — зелёный, прогрев — оранжевый (тепло), заморозка — голубой (холод), удалённые и мёртвые — тёмные. Строки мёртвых аккаунтов (dead, expired, banned) уходят на 50% прозрачности — они есть в таблице, но не отвлекают внимание.

Параллельно обновили status_colors в app.py и расширили valid_statuses — список допустимых значений при смене статуса через API. Теперь PATCH-запрос принимает все 7 статусов.

Добавили метод get_dashboard_stats() в database.py — он возвращает агрегированную статистику: сколько аккаунтов в каждом статусе, сколько AI-диалогов запущено, сколько аккаунтов в прогреве прямо сейчас. Главная страница получила 6 карточек вместо 3.

Все 37 тестов прошли с первого запуска.

Шаг 2: Bootstrap блокируется в России

Когда починили CSS, обнаружилась следующая проблема: вкладки на странице Settings не переключаются. HTML рендерится корректно, API отвечает, данные есть — но клик по вкладке ничего не делает.

Подозрение сразу упало на CDN. base.html загружал Bootstrap с cdn.jsdelivr.net с integrity-хешем:

<!-- Было: CDN -->
<link rel="stylesheet" 
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
  integrity="sha384-..." crossorigin="anonymous">
<script 
  src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
  integrity="sha384-..." crossorigin="anonymous"></script>

jsdelivr.net в России периодически блокируется или недоступен с высокими задержками. Если JavaScript не загрузился — Bootstrap-компоненты (табы, dropdown, тосты) просто не инициализируются. CSS при этом может подгрузиться из кеша браузера, поэтому страница выглядит нормально, но не работает.

Решение — перенести Bootstrap локально:

# Скачали CSS и JS
wget https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css \
  -O src/web/static/bootstrap.min.css
wget https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js \
  -O src/web/static/bootstrap.bundle.min.js
<!-- Стало: локальные файлы -->
<link rel="stylesheet" 
  href="{{ url_for('static', filename='bootstrap.min.css') }}">
<script 
  src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>

Дополнительно добавили vanilla JS fallback для табов — на случай, если Bootstrap JS по какой-то причине не инициализирует табы:

// Fallback для Bootstrap tabs
document.addEventListener('DOMContentLoaded', function() {
  const tabLinks = document.querySelectorAll('[data-bs-toggle="tab"]');
  if (tabLinks.length && typeof bootstrap === 'undefined') {
    tabLinks.forEach(link => {
      link.addEventListener('click', function(e) {
        e.preventDefault();
        const target = document.querySelector(this.getAttribute('data-bs-target'));
        document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('show', 'active'));
        tabLinks.forEach(l => l.classList.remove('active'));
        target.classList.add('show', 'active');
        this.classList.add('active');
      });
    });
  }
});

После этого Settings заработал полностью — вкладки переключаются, формы сохраняются.

Шаг 3: Корневая причина — два источника данных

Но оставалась главная проблема: изменения в Templates и Funnel не сохраняются. Точнее, они сохраняются — но в SQLite. А читает приложение при следующем запросе тоже из SQLite. Вот только данные о реальных аккаунтах и шаблонах живут в PostgreSQL.

Это случилось из-за миграции: в какой-то момент worker-процесс переехал на PostgreSQL, а Flask-приложение осталось на старом Database (SQLite-адаптере). В app.py функция _get_db() создавала SQLite-соединение:

# Было: SQLite
def _get_db():
    return Database(config.DB_PATH)  # SQLite файл
# Стало: PostgreSQL
def _get_db():
    return PgDatabase(config.DATABASE_URL)  # asyncpg

Но это только начало проблем. SQLite использует ? как placeholder для параметров, PostgreSQL — $1, $2. SQLite возвращает datetime как строки, PostgreSQL — как объекты datetime.datetime.

По всему app.py и шаблонам были разбросаны конструкции вроде:

# Падало с AttributeError: 'datetime' object has no attribute 'split'
created_at = account['created_at'].split('T')[0]
<!-- Jinja2 шаблон — TypeError: 'datetime' is not subscriptable -->
{{ account.created_at[:16] }}

Прошлись по всем местам и заменили:

# Универсальная обёртка
def fmt_dt(val):
    if isinstance(val, str):
        return val[:16]
    if hasattr(val, 'strftime'):
        return val.strftime('%Y-%m-%d %H:%M')
    return str(val)

В шаблонах добавили фильтр |datetimeformat вместо прямых срезов строк.

SQL-запросы переписали с ? на $1:

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

После этого весь дашборд начал читать и писать в PostgreSQL. Изменения шаблонов сохраняются, данные аккаунтов актуальные.

Шаг 4: Кейсы как медиа-альбом

Отдельная задача — шаг 5 воронки, где отправляются кейсы. Раньше каждая картинка отправлялась отдельным сообщением, что выглядело как спам. Нужно было отправлять их одним альбомом.

Telethon поддерживает отправку альбома через передачу списка файлов в send_file:

# Было: по одной картинке
for case_file in case_files:
    await client.send_file(target, case_file)
 
# Стало: альбом одним сообщением
case_files = sorted(glob.glob('assets/cases/*.png'))
if case_files:
    await client.send_file(
        target, 
        case_files,  # список = альбом
        caption=template_text
    )

Капция прикрепляется к последнему фото в альбоме — стандартное поведение Telegram API. Текст шаблона шага 5 обновили под этот формат: короткое сопроводительное сообщение, а не длинный текст.

Шаг 5: Мульти-воркер архитектура

После того как дашборд стабилизировался, перешли к более масштабной задаче — подготовке к горизонтальному масштабированию. Один воркер упирается в лимиты Telegram API при большом пуле аккаунтов. Решение — несколько независимых воркеров, координирующихся через Redis.

Phase 0 включала:

  • Redis в Docker — брокер задач и распределённые блокировки (порт 6380, т.к. 6379 был занят другим контейнером)
  • PostgreSQL схема — 15 таблиц в схеме tg_army, индексы, правильные grants
  • Миграция данных из SQLite в PG с верификацией row counts
  • PgDatabase — полная реплика SQLite Database API на asyncpg (47+ методов, 50 тестов)
  • RedisClient — обёртка для координации воркеров

Переход на asyncpg потребовал учесть несколько нюансов. Asyncpg не использует курсоры как sqlite3 — каждый запрос выполняется напрямую через pool:

# asyncpg connection pool
class PgDatabase:
    def __init__(self, dsn: str):
        self._dsn = dsn
        self._pool: asyncpg.Pool | None = None
    
    async def connect(self):
        self._pool = await asyncpg.create_pool(
            self._dsn,
            min_size=2,
            max_size=10
        )
    
    async def _fetchone(self, sql: str, *args):
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow(sql, *args)
            return dict(row) if row else None
    
    async def _fetchall(self, sql: str, *args):
        async with self._pool.acquire() as conn:
            rows = await conn.fetch(sql, *args)
            return [dict(r) for r in rows]
    
    async def _execute(self, sql: str, *args):
        async with self._pool.acquire() as conn:
            return await conn.execute(sql, *args)

Критически важный момент: asyncpg.Record — не словарь. Без dict(row) код, который делает record['field'], работает, но record.get('field', default) падает. Обернули все результаты в dict() на уровне адаптера.

Редис использовали для двух вещей: очередь задач (какой аккаунт какой воркер обрабатывает) и distributed lock (чтобы два воркера не взяли одновременно один аккаунт):

class RedisClient:
    async def acquire_account_lock(self, account_id: int, worker_id: str, ttl: int = 300) -> bool:
        key = f"lock:account:{account_id}"
        # SET NX EX — атомарная операция
        result = await self._redis.set(key, worker_id, nx=True, ex=ttl)
        return result is True
    
    async def release_account_lock(self, account_id: int, worker_id: str) -> bool:
        key = f"lock:account:{account_id}"
        # Lua script для атомарного check-and-delete
        script = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """
        result = await self._redis.eval(script, 1, key, worker_id)
        return result == 1

Результаты

После трёх сессий разработки:

  • 37 → 57 тестов, все зелёные
  • 9 багов дашборда закрыты полностью
  • Bootstrap отдаётся локально — нет зависимости от CDN
  • Единый источник данных — Flask и Worker читают из одной PostgreSQL
  • Медиа-альбом для кейсов — воронка работает как задумано
  • Инфраструктура для мульти-воркера готова: Redis, PgDatabase, миграция данных

Выводы

Самый дорогой баг в этом проекте — молчаливое расхождение источников данных. Flask писал в SQLite, Worker читал из PostgreSQL. Оба работали без ошибок. Пользователь видел, что изменения «сохраняются» (HTTP 200), но при следующей загрузке страницы они пропадали. Такие баги не кричат о себе — их нужно специально искать. Поэтому в любом проекте, где есть несколько сервисов, важно с самого начала договориться об одном источнике истины и проверять это в тестах.

Второй урок — зависимость от внешних CDN в production. Для разработки CDN удобен: не нужно ничего качать, всегда свежая версия. Но для production-инструментов, которыми пользуются люди из России или из нестабильных сетей, внешние зависимости — это риск. Bootstrap в 300 КБ — разумная цена за отсутствие внешней точки отказа. Скачал один раз, положил в static/, забыл.

Третье: переход с SQLite на PostgreSQL — это не просто смена строки подключения. Разные плейсхолдеры (? vs $1), разные типы возвращаемых данных (строки vs объекты), разное поведение при конкурентных запросах. Если вы делаете такую миграцию, заложите время на аудит всех SQL-запросов и всех мест, где обрабатываются результаты. У нас ломались Jinja2-шаблоны, fromisoformat(), срезы строк — всё, что молчаливо предполагало «datetime — это строка».

Наконец, про Redis как координатор воркеров: SET NX EX — атомарная операция, которая решает классическую проблему «два процесса берут одну задачу». Но освобождение блокировки нужно делать через Lua-скрипт, иначе есть race condition: проверили что lock наш → между проверкой и удалением TTL истёк → другой воркер взял lock → мы его удалили. Всегда используйте check-and-delete атомарно.

Ссылки

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

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.

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

TG Army