Группы моков, Compare-вкладка и фикс SSE-перехватчика

Группы моков, Compare-вкладка и починенный SSE-перехватчик.
Рабочая сессия с фокусом на несколько направлений: переработал систему моков с нуля, добавил вкладку для сравнения сетевых сессий, исправил баг в SSE-интерцепторе который не перехватывал именованные события, и почистил навигацию по панели. Расскажу подробно про каждое.
Система групп для моков

До этого все моки лежали в одном плоском списке. Пять правил — нормально. Двадцать — уже неудобно: непонятно что к чему относится, нельзя быстро отключить связанный набор правил целиком, нельзя поделиться частью конфигурации.
Теперь моки организованы в группы. Каждая группа — именованный блок с собственным переключателем включения. Включил/выключил группу — её правила перестают матчиться, но индивидуальные состояния каждого мока сохраняются. Вернул обратно — всё как было.
Auth mocks (3) [●] ▶
● POST /api/login → 200
● POST /api/refresh → 401 300ms delay
○ POST /api/logout → 200 (disabled)
Error scenarios (2) [●] ▶
● GET /api/users → 500
● POST /api/orders → 503
Группы коллапсируются. При скрытом списке виден только заголовок с именем и счётчиком правил.
API
typescript
const dashboard = useNetworkDashboard()
// Группы
dashboard.addMockGroup('Auth mocks')
dashboard.renameMockGroup(groupId, 'Auth & Session')
dashboard.toggleMockGroup(groupId, false) // отключить группу целиком
dashboard.removeMockGroup(groupId)
// Правила внутри группы
dashboard.addMockToGroup(groupId, {
urlPattern: '/api/refresh',
method: 'POST',
response: {
status: 401,
body: { error: 'token_expired' },
delay: 300,
}
})
dashboard.updateMockInGroup(groupId, ruleId, { enabled: false })
dashboard.removeMockFromGroup(groupId, ruleId)
Старый addMock / removeMock остался для обратной совместимости — проксирует в группу с именем default, которая создаётся автоматически.
Реактивное состояние — через mockGroups: Ref<readonly MockRulesGroup[]> в useNetworkDashboard(). Любой компонент, подписанный на него, получит обновление при изменении любой группы или правила. Хранение — в localStorage под ключом vue-network-dashboard:mockGroups (если включён persistToStorage).
Инлайн-форма внутри группы
Форма добавления и редактирования мока открывается прямо внутри блока группы — сразу под списком её правил. Не в отдельном попапе, не в модальном окне, не внизу экрана.
Почему это важно: когда форма появляется внизу экрана, ты теряешь из виду контекст — непонятно куда именно добавляешь правило, особенно если группы не влезают одновременно. Инлайн — форма всегда рядом с группой, к которой относится.
При нажатии кнопки «+» в заголовке группы — если она свёрнута, раскрывается автоматически, и форма появляется снизу. Нажатие «Edit» на правиле — форма та же, но заполненная значениями этого правила.
Переименование через двойной клик
Двойной клик на имени группы переводит его в режим редактирования: на месте текста появляется <input> с автофокусом и выделенным содержимым. Enter или потеря фокуса — сохранение. Escape — отмена.
Ничего лишнего в интерфейсе: нет кнопки «Rename», нет отдельного диалога. Обнаруживается интуитивно при взаимодействии.
Инлайн-диалоги вместо системных prompt и confirm
Раньше создание группы вызывало браузерный prompt(), а удаление — confirm(). Работает, но выглядит чужеродно: системный диалог поверх всего, блокирует вкладку, не вписывается в тему оформления.
Заменил оба на инлайн-решения.
Создание группы: кнопка «New group» в тулбаре добавляет карточку в начало списка групп — поле ввода имени с двумя кнопками. Список при этом полностью скроллируется, карточка часть потока, ничего не перекрыто.
┌──────────────────────────────────────────┐
│ [ Group name... ] Cancel Create │
└──────────────────────────────────────────┘
Auth mocks (3) [●] ▶
Error scenarios (2) [●] ▶
Enter подтверждает, Escape отменяет.
Удаление группы: при нажатии иконки удаления шапка группы заменяется строкой подтверждения прямо на её месте:
Delete "Error scenarios" and 2 mock(s)? [Cancel] [Delete]
Шапка стала красноватой — видно, что это деструктивное действие. Занимает ровно то же место, ничего не перекрывает. Остальные группы остаются на своих местах.
Почему не оверлей: я сначала сделал именно оверлей (position: absolute; inset: 0 на .mock-panel). Смотрится красиво, но ломает скролл списка групп — оверлей перехватывает все pointer-события, включая колёсико мыши. Инлайн-подход решает обе задачи одновременно.
Импорт и экспорт конфигурации моков
Кнопки Import и Export добавились в тулбар Mock-панели.
Экспорт сериализует все группы со всеми правилами в JSON и скачивает файл mock-config.json:
json
{
"version": 1,
"groups": [
{
"id": "grp_a1b2c3",
"name": "Auth mocks",
"enabled": true,
"isOpened": true,
"rules": [
{
"id": "rule_x7y8z9",
"name": "Block token refresh",
"urlPattern": "/api/refresh",
"method": "POST",
"enabled": true,
"response": {
"status": 401,
"body": { "error": "token_expired" },
"delay": 300
}
}
]
}
]
}
Импорт читает такой файл через FileReader и передаёт в replaceMockGroups() — метод заменяет текущую конфигурацию целиком. Не мержит, а заменяет: предсказуемо, без конфликтов ID.
Сценарии, где это полезно:
- Набор моков для конкретного сценария тестирования (happy path, ошибки авторизации, недоступный бэкенд) — держишь несколько файлов, переключаешься за секунды
- Шаринг с командой: фронтендер настроил набор правил под фичу — экспортировал, коллега импортировал, увидел ровно то же поведение
- Демо-режим: подготовленная конфигурация моков для показа заказчику, без зависимости от состояния сервера
Вкладки выше фильтров
Небольшое, но важное изменение в структуре панели. Раньше порядок был:
┌─ Шапка (кнопки управления) ──────────────────┐
│─ FilterBar ──────────────────────────────────│
│─ Вкладки: Logs / Stats / Timeline / Mocks ───│
│─ Контент вкладки ────────────────────────────│
└─ Футер ──────────────────────────────────────┘
Теперь:
┌─ Шапка ──────────────────────────────────────┐
│─ Вкладки: Logs / Stats / Timeline / Mocks ───│
│─ FilterBar (только на вкладке Logs) ─────────│
│─ Контент вкладки ────────────────────────────│
└─ Футер ──────────────────────────────────────┘
Вкладки теперь являются частью навигации, а не контента — поэтому логично, что они выше фильтров. Плюс: фильтры и Import-баннер скрываются на вкладках Stats, Timeline, Mocks, Compare — там они не нужны и занимали место:
vue
<template v-if="activeTab === 'logs'">
<FilterBar v-model:filters="filters" :logs="sourceLogs" />
<div v-if="importedLogs" class="import-banner">...</div>
</template>
Вкладка Compare

Новая вкладка для сравнения двух сетевых сессий. Загружаешь два HAR-файла — видишь diff: какие запросы появились, исчезли, изменились.
Типичный сценарий: что-то стало работать медленнее между деплоями. Экспортировал HAR до деплоя и после — открыл Compare — сразу видишь какие запросы стали тяжелее или дольше.
Как устроен diff
Матчинг записей — по составному ключу METHOD URL. Если запрос есть в обеих сессиях — сравниваются статус, длительность и размер ответа. Порог для «изменилось» — 20%: разница в 5ms на 200ms-запросе не считается значимой, но разница в 400ms — считается.
typescript
interface DiffRow {
kind: 'added' | 'removed' | 'changed' | 'same'
method: string
url: string
entryA: UnifiedLogEntry | null // null если запроса нет в сессии A
entryB: UnifiedLogEntry | null // null если запроса нет в сессии B
statusChanged: boolean
durationChanged: boolean
sizeChanged: boolean
}
Отображение
Два столбца, разделённые линией. Слева — сессия A, справа — сессия B. Для изменённых строк правый столбец подсвечивается янтарным, изменённые значения — отдельным акцентом. Рядом с значением показывается дельта: +42ms, −1.2 KB.
Запросы, которых нет в левой сессии, отображаются с зелёным фоном справа (added). Которых нет в правой — тускнеют слева (removed).
Фильтр по типу изменений: All / Added / Removed / Changed / Unchanged — таблетки над списком.
Загрузка файлов
Каждая сессия — зона drag & drop или клик для выбора файла. Поддерживаются HAR-файлы — те же, что экспортирует сам плагин. parseHAR конвертирует записи в UnifiedLogEntry[], дальше всё работает на том же слое данных.
Фикс SSE: именованные события не логировались
Разбирался почему SSE-записи в панели показывают только начальное подключение, а сами сообщения — нет.
Как работает EventSource
SSE-протокол различает два вида событий на уровне текстового формата стрима:
# Безымянное событие (event type по умолчанию — 'message')
data: {"hello": "world"}
# Именованное событие
event: ping
data: {"time": "2024-01-01T00:00:00Z", "count": 42}
event: update
data: {"userId": 123, "status": "online"}
Браузер обрабатывает их по-разному: безымянное событие стреляет 'message' на объекте EventSource, именованное — стреляет событие с соответствующим именем. То есть event: ping → es.addEventListener('ping', handler). Общего «поймать всё» события нет.
Что было не так
Интерцептор в attachEventListeners навешивал только четыре слушателя:
typescript
es.addEventListener('open', ...)
es.addEventListener('message', ...) // ← только безымянные события
es.addEventListener('error', ...)
es.addEventListener('close', ...)
Все именованные события — ping, update, heartbeat, любые кастомные — проходили насквозь незамеченными. На sse.dev/test, например, сервер шлёт исключительно event: ping, поэтому в панели была только запись о подключении с нулевыми данными.
Решение
Нельзя узнать заранее какие именованные события будет слать конкретный SSE-сервер — нет стандартного способа получить список. Но можно перехватить момент, когда код приложения регистрирует слушатель на новый тип.
Переопределяю addEventListener на каждом инстансе EventSource сразу после его создания:
typescript
const builtinTypes = new Set(['open', 'message', 'error', 'close'])
const hookedTypes = new Set<string>()
const originalAdd = es.addEventListener.bind(es)
;(es as any).addEventListener = function(type, listener, options) {
// Первый раз видим этот тип — добавляем свой logging-слушатель
if (!builtinTypes.has(type) && !hookedTypes.has(type) && listener !== null) {
hookedTypes.add(type)
originalAdd(type, (event: Event) => {
if (event instanceof MessageEvent) {
self.logMessage(es, context, event, type) // 'ping', 'update', etc.
}
})
}
return originalAdd(type, listener, options)
}
Когда код приложения вызывает es.addEventListener('ping', handler) — наш overrided метод добавляет в том числе собственный logging-слушатель для 'ping'. Следующие вызовы addEventListener('ping', ...) — уже в hookedTypes, logging-слушатель не дублируется.
Стандартные четыре типа (open, message, error, close) подключаются через originalAdd — до того как addEventListener переопределён — чтобы не попасть в рекурсию.
Ограничение, которое принял: если код приложения вообще не вешает слушатель на 'ping', а сервер всё равно шлёт такие события — они не будут залогированы. Без полной замены EventSource на fetch-based реализацию с ручным парсингом стрима это не решить. Для подавляющего большинства реальных случаев — когда приложение слушает те события, которые сервер шлёт — это работает.
Заодно поправил формат данных в записях
В форматтере SSE response.body хранил обёрточный объект:
typescript
// Было
response: {
body: {
eventType: params.eventType, // дублирует log.sse.eventType
data: eventData,
lastEventId: params.lastEventId, // дублирует log.sse.lastEventId
readyState: params.readyState // дублирует log.sse.readyState
}
}
Из-за этого при подключении (где eventType, data, lastEventId — все null) в панели отображалось:
json
Body
{
"eventType": null,
"data": null,
"lastEventId": null,
"readyState": 0
}
И секция Data показывала то же самое — потому что обе секции рендерили log.response.body.
Исправил: response.body теперь содержит только сами данные события — eventData. Для connection, open, close, error — null. Для message — реальные данные. SSE-специфичные поля (eventType, lastEventId, readyState) хранятся в log.sse и больше нигде не дублируются.
В LogEntry секция Body (которая предназначена для HTTP-ответов) теперь скрыта для SSE и WebSocket — у них есть отдельная секция Data:
typescript
// Было
v-if="hasBody(log.response.body)"
// Стало
v-if="log.type === 'http' && hasBody(log.response.body)"
Всё без новых зависимостей. Группы, импорт/экспорт конфигурации, Compare, починенный SSE — стандартные браузерные API и реактивность Vue.
NPM: https://www.npmjs.com/package/vue-network-dashboard
GitHub: https://github.com/macrulezru/vue-network-dashboard