Skip to content

Декоратор withLongPolling

Практические кейсы применения декоратора withLongPolling (Архитектура v2: Event-Driven)

Обновленный декоратор withLongPolling — это высокопроизводительный инструмент для создания инфраструктуры обновлений в реальном времени (Real-Time Updates) на базе стандартного HTTP-протокола. В архитектуре v2 декоратор полностью избавлен от скрытых фоновых циклов while и рекурсивных таймаутов, которые ломали планировщик стейт-менеджера.

Теперь он работает как чистый командный тактер: выполняет ровно одну «длинную» сетевую транзакцию, а затем декларативно просит бизнес-сервис сдвинуть реактивный сигнал pollingTick вперед. Изменение сигнала инвалидирует кэш ресурса engine.resource, заставляя ядро движка абсолютно легитимно и нативно открывать следующее HTTP-соединение.


Архитектурные кейсы применения

1. Системы мгновенных push-нотификаций (Live Notifications / Toasts)
  • Проблема без декоратора: Нам нужно выводить всплывающие уведомления прямо в процессе работы пользователя с системой. Короткий поллинг (Short Polling) раз в 2 секунды генерирует колоссальный холостой трафик и забивает логи веб-сервера. Развертывание WebSockets — избыточно и требует настройки проксирования и усложнения авторизации на бэкенде.
  • Решение с Long Polling v2: Ресурс связывается с реактивным кортежем зависимостей [isLoopActive, pollingTick]. Клиент открывает одно HTTP-соединение. Сервер держит его и молчит. Как только в базе данных появляется системное событие, сервер мгновенно завершает запрос. Декоратор принимает пакет, а его встроенная микро-пауза (delay: 500) дает слою отображения React время на плавную анимацию отрисовки Toast-карточки, после чего легитимно продвигает pollingTick для открытия следующего чистого канала связи.
2. Офлайн-буферизация событий и Offline-First чаты (Мессенджеры / Чат-комнаты)
  • Проблема: Внутренние CRM-системы или чаты поддержки требуют мгновенной синхронизации сообщений. При этом пользователь может зайти в лифт или цокольный этаж, где временно пропадет мобильный интернет. Если интерфейс в этот момент заблокирует кнопки отправки сообщений, это сломает UX.
  • Решение с Long Polling v2: Кнопки генерации сообщений остаются активными всегда. В режиме оффлайна все клики пользователя беспрепятственно накапливаются в реактивном сигнале левой колонки (outgoingBuffer). Декоратор v2 содержит механизм Токенизации сессий ошибок. Когда сеть восстанавливается, алгоритм Exponential Backoff плавно выводит ресурс из оффлайна, декоратор сжигает все старые зависшие таймауты ошибок в фоне и пакетно (одним махом) выгружает весь накопившийся оранжевый буфер на сервер за одну транзакцию, мгновенно перенося элементы в правую зелёную колонку успешной истории.
3. Мониторинг статуса тяжелых фоновых задач (Background Jobs / Export)
  • Проблема: Пользователь нажимает кнопку «Сгенерировать тяжелый Excel-отчет за 3 года». Сервер обрабатывает эту задачу в фоновой очереди (например, через Celery/BullMQ) в течение 10–40 секунд. Заставлять клиента ждать один бесконечный синхронный HTTP-запрос нельзя — соединение гарантированно отвалится по таймауту браузера или Cloudflare Gateway Timeout ($504$).
  • Решение с Long Polling v2: При клике сервер мгновенно возвращает jobId задачи, который записывается в реактивный кортеж зависимостей ресурса. Ресурс, обернутый в обновленный withLongPolling, опрашивает статус этой конкретной задачи. Соединение висит на сервере и закрывается только тогда, когда статус в бэкенде сменился на completed. Применение декларативного поля responseValidate гарантирует, что структура ответа провалидируется на уровне ядра, а в UI отобразится живой прогресс-бар.
4. Фиды данных и живые котировки (Live Dashboards / Sports Tracker)
  • Проблема: Отображение динамически меняющихся панелей (актуальный счет футбольного матча или котировки валют). Данные могут обновляться неравномерно: то 5 раз за секунду, то молчать 10 минут.
  • Решение с Long Polling v2: Позволяет не гонять пустые такты часов. Если изменений нет, соединение удерживается. Новая архитектура декоратора v2, благодаря тесной интеграции с CountdownService через Dependency Injection, позволяет плавно раздвигать окна блокировок при микро-сбоях инфраструктуры, информируя пользователя в статус-баре о точной секунде следующей автоматической попытки опроса.

Сводная шпаргалка по поведению в экосистеме графа сигналов:

  • Изоляция побочных эффектов: Бизнес-валидация структуры ответа полностью инкапсулирована в предикат responseValidate. Сайд-эффект наполнения истории вынесен в чистый engine.effect. Это гарантирует стабильность баджей и числовых счетчиков колонок дашборда — они меняются атомарно и без Race Conditions.
  • Истинный ленивый сон и защита от дублирования: Пока рубильник isLoopActive равен false, метод validateBeforeFetch закрывает заслонку. Ресурс выбрасывает контролируемую ошибку отмены и безопасно спит, полностью защищая приложение от паразитного мерцания сети (запросы больше не утраиваются при первом монтировании компонента).
  • Жесткий рубильник (Destroy): Нажатие кнопки Destroy переключает семафор isLoopActive в false. Вычисляемый кортеж мгновенно мутирует, ядро движка автоматически активирует нативный abortSignal, обрывая текущий висящий в сети fetch(), а токенизация сессий в withLongPolling намертво выжигает фоновые макрозадачи setTimeout из оперативной памяти за 0 миллисекунд.

Практические примеры интеграции декоратора withLongPolling с engine.resource

Ниже представлены два канонических примера использования доработанного декоратора версии v2 в архитектуре @pravosleva/reactive-engine. Оба примера строго следуют философии движка: управление жизненным циклом потока осуществляется через вычисляемые кортежи зависимостей (computed), бизнес-валидация структуры ответа инкапсулирована в responseValidate, а низкоуровневые сетевые ошибки выбрасываются через throw для корректного триггера Exponential Backoff.


Пример 1: Панель мониторинга фоновых задач (Background Job Tracker Dashboard)

Кейс применяется для отслеживания тяжелых серверных процессов (генерация Excel-отчетов, импорт XML/CSV). Ресурс лениво опрашивает статус задачи. Если задача завершилась — цикл останавливается, если нет — удерживает канал. При сбое сети (например, 504 Gateway Timeout) декоратор плавно раздвигает окна опроса через Backoff.

ts
import { BaseREService } from '../../BaseREService'
import { withLongPolling } from '../../decorators/withLongPolling'
import { CountdownService } from '../infrastructure/CountdownService'

interface JobStatusResponse {
  ok: boolean
  jobId: string
  status: 'pending' | 'processing' | 'completed' | 'failed'
  progress: number
  downloadUrl?: string
}

// Кортеж зависимостей: [Id задачи, Шаг авто-такта поллинга, Сигнал принудительной остановки]
type TJobDeps = [string | null, number, boolean];

export class LiveNotificationsLogic extends BaseREService {
  public countdown = this.engine.inject(CountdownService)
  public userIdSignal = this.createSignal<string>('user-123')
  public pollingTick = this.createSignal<number>(0)

  private apiDeps = this.engine.computed(() => [
    this.userIdSignal.value,
    this.pollingTick.value
  ])

  public notificationsResource = this.engine.resource(
    withLongPolling(
      async (deps, abortSignal) => {
        const response = await fetch(`/api/notifications?userId=${deps}`, { signal: abortSignal })
        if (!response.ok) throw new Error('Fetch failed')
        return response.json()
      },
      {
        onNextTick: () => { this.pollingTick.value += 1 },
        onStartBackoff: (ms, onComplete) => this.countdown.start(ms, onComplete),
        delay: 500,
        errorInitialDelay: 2000,
        errorMaxDelay: 8000
      }
    ),
    this.apiDeps
  )
}

Пример 2: Чат-лента поддержки в реальном времени (Support Live Chat Feed)

Кейс, где Long Polling удерживается сервером до 30 секунд. При получении новых сообщений они пакетно пушатся в начало истории, а pollingTick увеличивается для открытия следующего «длинного» соединения. Смена комнаты чата (activeRoomId) мгновенно обрывает старый fetch за счет нативного abortSignal движка, предотвращая утечки и перекрестное наложение ответов чатов.

ts
import { BaseREService } from '../../BaseREService'
import { withLongPolling } from '../../decorators/withLongPolling'
import { CountdownService } from '../infrastructure/CountdownService'

export interface MessageItem {
  id: string
  text: string
  author: string
  timestamp: string
}

interface ChatResponse {
  ok: boolean
  roomId: string
  messages: MessageItem[]
}

// Кортеж зависимостей: [Id активной комнаты, Шаг авто-такта поллинга]
type TChatDeps = [string, number];

export class LiveChatService extends BaseREService {
  private countdown = this.engine.inject(CountdownService)
  public activeRoomId = this.createSignal<string>('room-general')
  public pollingTick = this.createSignal<number>(0)

  private chatDeps = this.engine.computed(() => [
    this.activeRoomId.value,
    this.pollingTick.value
  ])

  public chatResource = this.engine.resource(
    withLongPolling(
      async (deps, abortSignal) => {
        const response = await fetch(`/api/chat/stream?room=${deps}`, { method: 'POST', signal: abortSignal })
        if (!response.ok) throw new Error('Chat gateway error')
        return response.json()
      },
      {
        onNextTick: () => { this.pollingTick.value += 1 },
        onStartBackoff: (ms, onComplete) => this.countdown.start(ms, onComplete),
        delay: 300,
        errorInitialDelay: 3000,
        errorMaxDelay: 12000
      }
    ),
    this.chatDeps
  )
}

Пример использования

Ниже приведен готовый пример использования сервиса:

ts
import { AbstractService, withLongPolling } from '@pravosleva/reactive-engine'
import { CountdownService } from './service.CountdownService'

export interface NotificationItem {
  id: string
  text: string
  timestamp: string
  type: 'like' | 'message'
}

interface ServerResponse {
  ok: boolean
  notifications: NotificationItem[]
}

// Из кортежа убран сигнал isServerOnline.
// Теперь запуск триггерится строго канонично: по шагу времени или фокусу вкладки!
type TPollingDeps = [isLoopActive: boolean, pollingTick: number, isTabActive: boolean];

export class LiveNotificationsLogic extends AbstractService {
  public countdown = this.engine.inject(CountdownService)

  // Источники правды для UI и графа
  public userIdSignal = this.createSignal<string>('user-123', 'live:signal:user-id')
  public isServerOnline = this.createSignal<boolean>(true, 'live:signal:server-status')
  public pollingTick = this.createSignal<number>(0, 'live:signal:tick')
  public isLoopActive = this.createSignal<boolean>(false, 'live:signal:loop-active')
  public isTabActive = this.createSignal<boolean>(true, 'live:signal:tab-active')

  public receivedNotifications = this.createSignal<NotificationItem[]>([], 'live:signal:feed-list')
  public outgoingBuffer = this.createSignal<NotificationItem[]>([], 'live:signal:outgoing-buffer')

  private feedSessionId = this.createSignal<string>(Math.random().toString(), 'live:signal:session-id')
  private resourceSessionMap = new Map<any, string>()

  // Метрики
  public requestCount = this.createSignal<number>(0, 'live:signal:request-count')
  public currentStatus = this.createSignal<string>('🛑 Поллинг остановлен. Нажмите Init для запуска.', 'live:signal:status')

  // Очищенный реактивный кортеж зависимостей
  private apiDeps = this.engine.computed<TPollingDeps>(() => [
    this.isLoopActive.value,
    this.pollingTick.value,
    this.isTabActive.value
  ], 'live:computed:apiDeps')

  /**
   * ДЕКЛАРАТИВНЫЙ РЕСУРС ЛОНГ-ПОЛЛИНГА
   * Полностью переведен на v2 Options API декоратора withLongPolling с методом onError.
   */
  public notificationsResource = this.engine.resource<ServerResponse | null, TPollingDeps>(
    withLongPolling(
      async (deps, abortSignal) => {
        const [isLoopActive, pollingTick, isTabActive] = deps

        if (!this.isServerOnline.value) {
          this.currentStatus.value = `🚨 Ошибка 502: Нет связи. Буферизируем...`
          throw new Error('502 Bad Gateway')
        }

        this.requestCount.value += 1
        this.currentStatus.value = `📡 [Такт: ${pollingTick}] Удерживаем длинное соединение...`

        const notificationsPayload = [...this.outgoingBuffer.value]
        this.outgoingBuffer.value = []

        const mockPayload: ServerResponse = { ok: true, notifications: notificationsPayload }
        const queryParams = new URLSearchParams({
          userId: String(this.userIdSignal.value),
          tick: String(pollingTick),
          _addData: JSON.stringify(mockPayload)
        }).toString()

        try {
          const response = await fetch(`/fake-feed-vite-proxy/?${queryParams}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ userId: this.userIdSignal.value, notifications: notificationsPayload }),
            signal: abortSignal
          })

          if (!response.ok) {
            this.outgoingBuffer.value = [...notificationsPayload, ...this.outgoingBuffer.value]
            throw new Error(`Ошибка HTTP: ${response.status}`)
          }

          const jsonResult = await response.json()
          this.resourceSessionMap.set(jsonResult, this.feedSessionId.value)
          return jsonResult
        } catch (error) {
          if (error instanceof DOMException && error.name === 'AbortError') throw error
          this.resourceSessionMap.set(mockPayload, this.feedSessionId.value)
          return mockPayload
        }
      },
      {
        onNextTick: () => this.nextTick(),

        /**
         * Реализует мост между ошибкой декоратора и инфраструктурным UI-таймером.
         *
         * @param {number} ms - Время ожидания в мс до следующей попытки (Backoff).
         * @param {() => void} onRetryScheduled - Сигнальный триггер для фиксации шага экспоненты.
         */
        onError: (ms: number, onRetryScheduled: () => void) => this.countdown.start(ms, onRetryScheduled),

        delay: 3000,
        errorInitialDelay: 2000,
        errorMaxDelay: 8000
      }
    ),
    this.apiDeps,
    {
      name: 'live:resource:notifications',
      resetDataOnSourceChange: true,
      validateBeforeFetch: () => this.isLoopActive.value && this.isTabActive.value,

      responseValidate: (res: any) => {
        const payload = res as ServerResponse | null
        if (!payload || payload.ok !== true) {
          this.currentStatus.value = '🚨 Ошибка валидации структуры ответа'
          return 'Некорректная структура JSON ответа'
        }

        const activeSession = this.feedSessionId.value

        // Сначала жестко проверяем наличие элементов в прилетевшей пачке бэкенда.
        // Если пачка не пустая — мы ВСЕГДА выводим статус успешной доставки, убирая дедлок "Канал чист"!
        if (payload.notifications && payload.notifications.length > 0) {
          const incomingItems = payload.notifications
          const currentList = this.receivedNotifications.value

          // Фильтруем дубликаты
          const filteredItems = incomingItems.filter(item => !currentList.some(exist => exist.id === item.id))

          if (filteredItems.length > 0) {
            this.receivedNotifications.value = [...filteredItems, ...currentList]
            this.currentStatus.value = `⚡ Успешно доставлен пакет из ${filteredItems.length} событий!`
          } else {
            // Если все элементы из пачки уже были в ленте ранее
            this.currentStatus.value = '⏳ Канал обновлен. Новые события уже обработаны.'
          }
        } else {
          // Строго если бэкенд прислал абсолютно пустой массив (нормальный таймаут удержания)
          this.currentStatus.value = '⏳ Канал чист. Новых событий на бэкенде нет.'
        }

        return true
      }
    }
  )

  public setTabVisibility(isActive: boolean) {
    this.isTabActive.value = isActive

    if (isActive) {
      if (this.isLoopActive.value) {
        this.currentStatus.value = '🔄 Вкладка снова активна. Возобновляем лонг-поллинг...'
        this.nextTick()
      }
    } else {
      this.countdown.stop()
      this.currentStatus.value = '⏸️ Вкладка неактивна. Лонг-поллинг временно заморожен.'
    }
  }

  /**
   * ИМПЕРАТИВНЫЙ СТАРТЕР (Init)
   * 🎓 Мы убрали вызов .refetch(). Переключения реактивного сигнала
   * теперь абсолютно достаточно, чтобы движок сам легитимно открыл соединение.
   */
  public startLongPolling = () => {
    if (this.isLoopActive.value) return
    this.isLoopActive.value = true
  }

  public nextTick() {
    if (this.isLoopActive.value && this.isTabActive.value) {
      this.pollingTick.value += 1
    }
  }

  public async triggerEventOnServer(text: string, type: 'like' | 'message') {
    const newItem: NotificationItem = {
      id: Math.random().toString(36).substring(2, 11),
      text,
      timestamp: new Date().toLocaleTimeString(),
      type
    }
    this.outgoingBuffer.value = [...this.outgoingBuffer.value, newItem]
    this.currentStatus.value = `⏳ Событие зафиксировано и ждет отправки.`

    // Если поллинг спит (Destroy) — мы НЕ должны пинать refetch ресурса!
    // События будут копиться в очереди, пока пользователь сам не нажмет Init.
    if (this.isLoopActive.value && this.isServerOnline.value && this.isTabActive.value) {
      this.notificationsResource.refetch()
    }
  }

  /**
   * Изменение isServerOnline больше не дергает транзакции планировщика напрямую.
   * Код работает плавно, без дедлоков рантайма.
   */
  public toggleServerStatus() {
    this.isServerOnline.value = !this.isServerOnline.value

    if (this.isServerOnline.value) {
      this.countdown.stop()
      this.currentStatus.value = '🔄 Сеть найдена. Возобновляем лонг-поллинг...'

      // Пинаем перезапуск только если сам поллинг сейчас активен
      if (this.isLoopActive.value) {
        this.notificationsResource.refetch()
      }
    } else {
      this.currentStatus.value = '🚨 Связь потеряна. Накапливаем буфер.'
      if (this.isLoopActive.value) {
        this.nextTick()
      }
    }
  }

  public clearFeed() {
    this.feedSessionId.value = Math.random().toString()
    this.receivedNotifications.value = []
    this.currentStatus.value = '🗑️ Лента успешно очищена.'
  }

  public destroy() {
    this.countdown.stop()
    this.isLoopActive.value = false
    this.pollingTick.value = 0
    this.resourceSessionMap.clear()
    this.outgoingBuffer.value = []
    this.receivedNotifications.value = []
    this.requestCount.value = 0
    this.isServerOnline.value = true
    this.currentStatus.value = '🛑 Поллинг остановлен. Нажмите Init для запуска.'
  }
}
tsx
import { useEffect } from 'react'
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { ReactiveEngine } from '@pravosleva/reactive-engine'
import { LiveNotificationsLogic } from './service.LiveNotificationsLogic'
import clsx from 'clsx'

const engine = new ReactiveEngine()

export const LiveNotificationsExample = () => {
  const logic = engine.inject(LiveNotificationsLogic)

  const userId = engine.use(logic.userIdSignal)
  const isServerOnline = engine.use(logic.isServerOnline)
  const isLoopActive = engine.use(logic.isLoopActive)
  const isTabActive = engine.use(logic.isTabActive) // Подписываемся на флаг вкладки
  const requestCount = engine.use(logic.requestCount)
  const currentStatus = engine.use(logic.currentStatus)

  const secondsToRetry = engine.use(logic.countdown.secondsLeft)
  const receivedNotifications = engine.use(logic.receivedNotifications)
  const outgoingBuffer = engine.use(logic.outgoingBuffer)

  // ИСПРАВЛЕНИЕ: Комплексный эффект инициализации и отслеживания активности вкладки
  useEffect(() => {
    logic.startLongPolling()

    const handleVisibilityChange = () => {
      // Передаем статус видимости вкладки (true/false) в бизнес-сервис
      logic.setTabVisibility(document.visibilityState === 'visible')
    }

    document.addEventListener('visibilitychange', handleVisibilityChange)

    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange)
      logic.destroy()
    }
  }, [logic])

  return (
    <div
      className={clsx(baseClasses.unit, baseClasses.stack2)}
      style={{ padding: '24px', fontFamily: 'system-ui', width: '600px' }}
    >
      <div className={baseClasses.absoluteUnitLabel}>
        Long Polling Live Stream Demo {!isTabActive && '⏸️'}
      </div>

      {/* Описание */}
      <div style={{ fontSize: 'small' }}>
        ⚙️ С помощью кнопок <strong>Init</strong> и <strong>Destroy</strong> вы можете полностью
        запускать и гасить фоновые сетевые потоки. Переключение между ними абсолютно взаимоисключающее.
      </div>

      {/* УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ (Строго ваша стилистика кнопок) */}
      <div style={{ display: 'flex', gap: '16px', width: '100%' }}>
        <button
          disabled={isLoopActive}
          onClick={() => logic.startLongPolling()}
          className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], {
            [btnClasses['neonBtn--outlined']]: isLoopActive,
            [btnClasses['neonBtn--contained']]: !isLoopActive,
          })}
          style={{ flex: 1, opacity: isLoopActive ? 0.3 : 1 }}
        >
          Init
        </button>
        <button
          disabled={!isLoopActive}
          onClick={() => logic.destroy()}
          className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--danger'], btnClasses['neonBtn--outlined'])}
          style={{ flex: 1, opacity: !isLoopActive ? 0.3 : 1 }}
        >
          Destroy
        </button>
      </div>

      {/* Индикаторы сети */}
      <div style={{ display: 'flex', justifyContent: 'space-between', background: '#15151a', color: '#fff', padding: '16px', borderRadius: '8px', fontSize: '13px' }}>
        <div>
          Сессия: <strong style={{ color: '#00b4d8' }}>{userId}</strong>
          {!isTabActive && <span style={{ color: '#ffb74d', marginLeft: '8px' }}>(Вкладка в фоне)</span>}
        </div>
        <div>Совершено HTTP-запросов: <strong style={{ color: '#e6af2e' }}>{requestCount}</strong></div>
      </div>

      {/* Переключатель сервера */}
      <div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'center' }}>
        <div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '14px' }}>
          <span style={{
            display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
            background: isServerOnline ? '#4caf50' : '#ef5350',
            boxShadow: isServerOnline ? '0 0 8px #4caf50' : '0 0 8px #ef5350'
          }} />
          <span>Бэкенд: <strong>{isServerOnline ? 'ONLINE' : 'OFFLINE (502)'}</strong></span>
        </div>

        <button
          disabled={!isLoopActive}
          onClick={() => logic.toggleServerStatus()}
          className={clsx(
            btnClasses.btn,
            btnClasses.neonBtn,
            {
              [btnClasses['neonBtn--primary']]: !isServerOnline,
              [btnClasses['neonBtn--danger']]: isServerOnline,
            },
            btnClasses['neonBtn--outlined']
          )}
        >
          {isServerOnline ? 'Выключить сервер' : 'Включить сервер'}
        </button>
      </div>

      {/* Статус-бар */}
      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '12px', minHeight: '38px', background: '#111', borderRadius: '8px', padding: '6px 12px', textAlign: 'center' }}>
        {!isServerOnline && isLoopActive ? (
          <span style={{ color: '#ef5350' }}>
            🚨 Сеть разорвана. Накапливаем буфер. Переподключение через: <strong style={{ fontSize: '14px', color: '#fff', background: '#3a1a1a', padding: '2px 6px', borderRadius: '4px', marginLeft: '4px' }}>{secondsToRetry} сек</strong>
          </span>
        ) : (
          <span style={{ color: !isLoopActive ? '#aaa' : currentStatus.includes('⚡') ? '#4caf50' : currentStatus.includes('⏳') ? '#e6af2e' : '#00b4d8', fontWeight: currentStatus.includes('⚡') ? 'bold' : 'normal' }}>
            {currentStatus}
          </span>
        )}
      </div>

      {/* Кнопки имитации действий */}
      <div style={{ display: 'flex', gap: '16px', width: '100%' }}>
        <button
          onClick={() => logic.triggerEventOnServer('Новый лайк к статье', 'like')}
          className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}
          style={{ flex: 1 }}
        >
          👍 Имитировать Лайк
        </button>
        <button
          onClick={() => logic.triggerEventOnServer('Новое сообщение чата', 'message')}
          className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}
          style={{ flex: 1 }}
        >
          💬 Имитировать Сообщение
        </button>
      </div>

      {/* Таблица колонок */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', width: '100%', marginTop: '8px' }}>

        <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
          <div style={{ fontSize: '12px', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <span>🟡 Те, что в очереди:</span>
            <span style={{ background: '#3a2510', color: '#ffb74d', padding: '2px 8px', borderRadius: '10px', fontSize: '11px', border: '1px solid #ffb74d' }}>{outgoingBuffer.length}</span>
          </div>
          <div
            style={{
              background: '#000',
              height: '180px',
              borderRadius: '8px',
              padding: '12px',
              overflowY: 'auto',
              display: 'flex',
              flexDirection: 'column',
              gap: '6px',
            }}
          >
            {outgoingBuffer.length === 0 ? (
              <span style={{ color: '#aaa', fontStyle: 'italic', fontSize: '13px', textAlign: 'center' }}>Очередь пуста.<br />Выключите сервер для накопления.</span>
            ) : (
              outgoingBuffer.map((item) => (
                <div key={item.id} style={{ color: '#ffb74d', fontSize: '13px', fontFamily: 'monospace', display: 'flex', justifyContent: 'space-between' }}>
                  <span>{item.type === 'like' ? '❤️' : '💬'} {item.text}</span>
                  <span style={{ color: '#666', fontSize: '10px' }}>{item.timestamp}</span>
                </div>
              ))
            )}
          </div>
        </div>

        <div className={baseClasses.stack1}>
          <div style={{ fontSize: '12px', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <span style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
              <span>🟢 Успешно отправленные:</span>
              <span style={{ background: '#122b15', color: '#4caf50', padding: '2px 8px', borderRadius: '10px', fontSize: '11px', border: '1px solid #4caf50' }}>{receivedNotifications.length}</span>
            </span>
            {receivedNotifications.length > 0 && (
              <button onClick={() => logic.clearFeed()} style={{ background: 'transparent', color: '#666', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontSize: '11px', padding: 0 }}>
                очистить
              </button>
            )}
          </div>
          <div
            style={{
              background: '#000',
              height: '180px',
              borderRadius: '8px',
              padding: '12px',
              overflowY: 'auto',
              display: 'flex',
              flexDirection: 'column',
              gap: '6px',
            }}
          >
            {receivedNotifications.length === 0 ? (
              <span style={{ color: '#aaa', fontStyle: 'italic', fontSize: '13px', textAlign: 'center' }}>Нет доставленных событий.<br />Кликните по кнопкам выше.</span>
            ) : (
              receivedNotifications.map((item) => (
                <div key={item.id} style={{ color: item.type === 'like' ? '#ff6b6b' : '#4caf50', fontSize: '12px', fontFamily: 'monospace', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                  <span style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
                    <span>{item.type === 'like' ? '❤️' : '💬'}</span>
                    <span>{item.text}</span>
                  </span>
                  <span style={{ color: '#555', fontSize: '11px' }}>{item.timestamp}</span>
                </div>
              ))
            )}
          </div>
        </div>

      </div>
    </div>
  )
}