Фикс дашборда TG Army: 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 потребовало нескольких правок:
- Изменить
_get_db()чтобы возвращалPgDatabaseвместоDatabase - Исправить все raw SQL-запросы с плейсхолдером
?(стиль SQLite) на$1, $2, ...(стиль asyncpg) - Убрать
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— полная реплика APIDatabase, 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. Пишу о реальных кейсах из продакшена.
Связанный проект
Смотреть в портфолио →