Декоратор 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.
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 движка, предотвращая утечки и перекрестное наложение ответов чатов.
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
)
}Пример использования
Ниже приведен готовый пример использования сервиса:
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 для запуска.'
}
}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>
)
}