У инвесторов в сети Gonka обычно 10-20 GPU-нод. Каждая нода — это адрес вида gonka1abc...xyz. Когда открываешь дашборд GonkaHub, видишь стену одинаковых строк. Все адреса начинаются с gonka1, все выглядят одинаково. Невозможно быстро понять, как работает конкретная группа серверов — например, ноды в одном датацентре или машины с определённым GPU. Приходится запоминать адреса наизусть или держать Excel с пометками.

Плюс блокчейн Gonka ввёл CPoC — Celestial Proof of Contribution. Это метрика, которая доказывает, что GPU реально работает и вносит вклад в сеть. Без CPoC нода может просто занимать слот, ничего не делая. С CPoC видно конкретный числовой вклад каждой GPU. Но этой метрики не было в GonkaHub — я показывал только rewards и uptime.

27-28 января 2026 я сделал 27 коммитов. Добавил группировку адресов и CPoC-метрику. Теперь можно раскидать адреса по группам ("Датацентр A", "RTX 4090", "Тестовые"), каждой присвоить цвет, фильтровать по группам и видеть агрегированные метрики.

JSONField вместо нормализации

Первый вопрос — где хранить группы? Можно сделать отдельную таблицу AddressGroup с foreign key на NetworkSubscriber. Классическая нормализация. Но у нас не миллионы записей. У одного подписчика максимум 20-30 адресов, 5-10 групп. JOIN на каждый запрос — это N+1 проблема, если не писать select_related.

Я выбрал JSONField прямо на модели NetworkSubscriber:

class NetworkSubscriber(models.Model):
    telegram_id = models.BigIntegerField(unique=True)
    addresses = models.JSONField(default=list)
    address_names = models.JSONField(default=dict)
    address_order = models.JSONField(default=list)
    address_groups = models.JSONField(default=dict)
    deleted_groups = models.JSONField(default=list)

address_groups — это словарь {group_id: {name, color, addresses}}. Один запрос к БД — получаешь subscriber со всеми группами. Никаких JOIN, никаких дополнительных запросов. Производительность линейная, код проще.

deleted_groups — массив group_id, которые пользователь удалил. Зачем? При синхронизации адресов с блокчейна я не хочу, чтобы удалённые группы "воскресали". Если пользователь удалил группу "Старые ноды", а потом эти адреса снова появились в блокчейне, группа остаётся удалённой.

CRUD для групп

Добавил четыре endpoint:

  • POST /api/groups/ — создание группы (name, color, addresses)
  • PUT /api/groups/{group_id}/ — редактирование (name, color, addresses)
  • DELETE /api/groups/{group_id}/ — удаление (добавляет group_id в deleted_groups)
  • GET /api/groups/ — список групп текущего подписчика

Хитрость с удалением. Я не удаляю группу из JSONField сразу. Сначала добавляю её id в deleted_groups, потом удаляю из address_groups. При следующей синхронизации с блокчейна метод refresh_subscriber_addresses() проверяет deleted_groups и не пересоздаёт удалённые группы.

def delete_group(self, group_id):
    if group_id not in self.deleted_groups:
        self.deleted_groups.append(group_id)
    if group_id in self.address_groups:
        del self.address_groups[group_id]
    self.save()

GroupChip и portal-меню

На фронте компонент GroupChip.tsx — цветной тег с названием группы, количеством адресов и меню edit/delete. Меню рендерится через React portal, чтобы избежать проблем с z-index внутри таблицы.

<button className={cn(
    'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm',
    'border border-gray-200 dark:border-gray-700',
    isSelected ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50'
)}>
    <span className="h-2.5 w-2.5 rounded-full"
          style={{ backgroundColor: color }} />
    <span className="truncate max-w-[100px]">{name}</span>
    {count !== undefined && (
        <span className="text-xs text-gray-400">{count}</span>
    )}
</button>

При клике на группу активируется фильтр — показываются только адреса из этой группы. Метрики агрегируются: суммарные rewards, средний CPoC, uptime по группе. Если у тебя группа "Продакшн" из 5 нод, ты видишь их общий вклад одной цифрой.

CPoC-колонка в таблице

CPoC — это числовое значение от 0 до 1, показывающее вклад GPU в сеть. Я добавил колонку в GonkaWalletTable.tsx рядом с rewards и uptime. Данные приходят из Gonka API при синхронизации:

<td className="px-6 py-4 text-sm">
    {wallet.cpoc !== undefined ? (
        <span className="text-gray-900 dark:text-gray-100">
            {wallet.cpoc.toFixed(4)}
        </span>
    ) : (
        <span className="text-gray-400"></span>
    )}
</td>

Backend парсит CPoC из ответа Gonka API и сохраняет в GonkaWallet.cpoc (DecimalField). При агрегации по группам считаю средний CPoC — avg(cpoc) в Django ORM.

Результат

27 коммитов за 2 дня. Полный CRUD для групп адресов. CPoC-метрика интегрирована в таблицу и агрегацию. Фильтрация по группам работает с сохранением состояния в localStorage.

Инвестор открывает дашборд, видит 5 цветных тегов ("Датацентр Москва", "Датацентр Рига", "Тестовые", "RTX 4090", "A100"), кликает на "Датацентр Москва" — таблица показывает только эти адреса, сверху общий CPoC и rewards по группе. Не нужно искать адреса глазами. Не нужен Excel. Всё в одном интерфейсе.

JSONField оправдал себя. Запросы к БД не изменились — один SELECT, всё остальное обрабатывается в Python. Если бы я сделал нормализованную таблицу, пришлось бы писать select_related, следить за N+1, возможно добавлять кеш. Здесь ничего этого не нужно. Данные маленькие, изменяются редко, производительность линейная.

CPoC — это не просто число. Это показатель, который отличает работающую GPU от "мёртвой". Если адрес получает rewards, но CPoC близок к нулю, значит что-то не так. Может, нода не синхронизировалась, может, GPU не отвечает на запросы. Я показываю это в дашборде, и инвестор видит проблемы до того, как они превратились в потерянный доход.