Как мы починили 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 проблем, которые мы решали
Перед стартом зафиксировали полный список:
- Отсутствуют CSS-стили для
frozen,warming,deleted - API не принимает эти статусы при смене через dropdown
- Dropdown в интерфейсе содержит только старые статусы
- Нет статистики по AI-диалогам на главном экране
- Нет статистики по прогреву аккаунтов
- Мёртвые аккаунты (
dead,expired,banned) не отделяются визуально - Вкладки на странице Settings не переключаются
- Изменения шаблонов воронки не сохраняются
- 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 атомарно.
Ссылки
- asyncpg — документация — быстрый PostgreSQL-драйвер для Python
- Bootstrap 5 — JavaScript компоненты — как работают табы и другие JS-компоненты
- Telethon send_file — документация — отправка медиа-альбомов
- Redis SET NX EX — атомарные операции для distributed locks
- asyncpg Record vs dict — почему Record не является dict

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