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

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

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


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


Система групп для моков

До этого все моки лежали в одном плоском списке. Пять правил — нормально. Двадцать — уже неудобно: непонятно что к чему относится, нельзя быстро отключить связанный набор правил целиком, нельзя поделиться частью конфигурации.

Теперь моки организованы в группы. Каждая группа — именованный блок с собственным переключателем включения. Включил/выключил группу — её правила перестают матчиться, но индивидуальные состояния каждого мока сохраняются. Вернул обратно — всё как было.

Copy
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 Copy
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» в тулбаре добавляет карточку в начало списка групп — поле ввода имени с двумя кнопками. Список при этом полностью скроллируется, карточка часть потока, ничего не перекрыто.

Copy
┌──────────────────────────────────────────┐
│  [  Group name...        ] Cancel Create │
└──────────────────────────────────────────┘
  Auth mocks (3)              [●] ▶
  Error scenarios (2)         [●]

Enter подтверждает, Escape отменяет.

Удаление группы: при нажатии иконки удаления шапка группы заменяется строкой подтверждения прямо на её месте:

Copy
  Delete "Error scenarios" and 2 mock(s)?   [Cancel] [Delete]

Шапка стала красноватой — видно, что это деструктивное действие. Занимает ровно то же место, ничего не перекрывает. Остальные группы остаются на своих местах.

Почему не оверлей: я сначала сделал именно оверлей (position: absolute; inset: 0 на .mock-panel). Смотрится красиво, но ломает скролл списка групп — оверлей перехватывает все pointer-события, включая колёсико мыши. Инлайн-подход решает обе задачи одновременно.


Импорт и экспорт конфигурации моков

Кнопки Import и Export добавились в тулбар Mock-панели.

Экспорт сериализует все группы со всеми правилами в JSON и скачивает файл mock-config.json:

json Copy
{
  "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, ошибки авторизации, недоступный бэкенд) — держишь несколько файлов, переключаешься за секунды
  • Шаринг с командой: фронтендер настроил набор правил под фичу — экспортировал, коллега импортировал, увидел ровно то же поведение
  • Демо-режим: подготовленная конфигурация моков для показа заказчику, без зависимости от состояния сервера

Вкладки выше фильтров

Небольшое, но важное изменение в структуре панели. Раньше порядок был:

Copy
┌─ Шапка (кнопки управления) ──────────────────┐
│─ FilterBar ──────────────────────────────────│
│─ Вкладки: Logs / Stats / Timeline / Mocks ───│
│─ Контент вкладки ────────────────────────────│
└─ Футер ──────────────────────────────────────┘

Теперь:

Copy
┌─ Шапка ──────────────────────────────────────┐
│─ Вкладки: Logs / Stats / Timeline / Mocks ───│
│─ FilterBar (только на вкладке Logs) ─────────│
│─ Контент вкладки ────────────────────────────│
└─ Футер ──────────────────────────────────────┘

Вкладки теперь являются частью навигации, а не контента — поэтому логично, что они выше фильтров. Плюс: фильтры и Import-баннер скрываются на вкладках Stats, Timeline, Mocks, Compare — там они не нужны и занимали место:

vue Copy
<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 Copy
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-протокол различает два вида событий на уровне текстового формата стрима:

Copy
# Безымянное событие (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: pinges.addEventListener('ping', handler). Общего «поймать всё» события нет.

Что было не так

Интерцептор в attachEventListeners навешивал только четыре слушателя:

typescript Copy
es.addEventListener('open', ...)
es.addEventListener('message', ...)  // ← только безымянные события
es.addEventListener('error', ...)
es.addEventListener('close', ...)

Все именованные события — ping, update, heartbeat, любые кастомные — проходили насквозь незамеченными. На sse.dev/test, например, сервер шлёт исключительно event: ping, поэтому в панели была только запись о подключении с нулевыми данными.

Решение

Нельзя узнать заранее какие именованные события будет слать конкретный SSE-сервер — нет стандартного способа получить список. Но можно перехватить момент, когда код приложения регистрирует слушатель на новый тип.

Переопределяю addEventListener на каждом инстансе EventSource сразу после его создания:

typescript Copy
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 Copy
// Было
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 Copy
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 Copy
// Было
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

Читать далее

20.04.2026

Vue Network Dashboard: трансформация ответов, брейкпоинты и OpenAPI-импорт

Новая порция фич для встраиваемого отладчика сети: теперь можно модифицировать реальные ответы на лету, замораживать запросы как в Charles Proxy и генерировать моки из OpenAPI-спеки одним кликом.

Метки
vuetypescriptdevtoolsnetworkdebuggingopensource