Регистрация на Rustvy по реферальной ссылке автора: https://rustvy.xyz/r/Z6LJC8FH
При регистрации по этой ссылке вы получаете скидку 10% и бонусные токены.
Подробные условия акции: https://rustvy.xyz/dashboard/referral
Rustvy (dsh-rustvy)
Русский | English
Значки производителей в списке моделей DeepSeek Harness, список моделей, который
остаётся настолько же актуальным, насколько актуален сам провайдер, и несколько
ключей провайдера в одном профиле — каждый чат отправляет запросы тем, который
выбрал. Один плагин, одно зеркало, без перезапусков.
Что он делает
У плагина три задачи. Первые две работают от одного зеркала публичного каталога
провайдера; третьей зеркало не нужно — ей нужен только маршрут профиля.
Значки. Один монохромный значок производителя перед каждой строкой модели в
списке выбора — нарисованный так же, как его рисует собственная панель провайдера:
CSS-маска, заливаемая currentColor, поэтому один файл на производителя
обслуживает обе темы оформления и все состояния строки — наведение, выбор,
отключение — без цветовых вариантов.
| Где | Что появляется |
|---|
| Меню моделей в композере | монохромный значок 16 px перед каждой строкой модели |
| Кнопка модели в композере | значок выбранной модели перед её названием (14 px) |
Всплывающее окно команды /model | тот же значок на каждой строке |
Скины, перерисовывающие список (dsh-claude-style) | тот же значок; собственная плашка скина в этой строке прячется, чтобы две плашки не встретились |
Список моделей. Профиль подключается к стороннему провайдеру через вручную
объявленный маршрут @deepseek-ai/dsh-llm-pi-ai, и именно список models этого
маршрута определяет, что вообще можно выбрать в интерфейсе: harness предлагает
модель только тогда, когда её отдаёт маршрут. Список, написанный вручную, устаревает
в тот день, когда провайдер выпускает новую модель — новая невидима, а снятая с
публикации остаётся выбираемой и падает уже на запросе. Плагин читает, что отдаёт
собственный эндпоинт маршрута, превращает это в точный формат записей адаптера,
показывает расхождение с установленным списком и по команде записывает его обратно
через ctx.configEditor.
Ключи. Несколько ключей провайдера в одном профиле и свой ключ для отдельного
чата — выбираемый в панели инструментов композера, сразу за списком режимов
доступа. Ключ добавляется в самом плагине, кнопкой «+»: ни .env, ни
cordis.patch.yml, ни страницу Models трогать не нужно, а выбор переживает
перезапуск. Учётная запись DeepSeek не затрагивается: подмена касается только
маршрутов этого плагина.
| Где | Что появляется |
|---|
| Настройки → Rustvy | раздел «API-ключи»: список ключей, кнопка «+», замена значения, переименование, проверка, назначение ключом по умолчанию, удаление |
| Композер, сразу за списком режимов доступа | компактный переключатель ключа этого чата, с меню и строкой «+ Добавить ключ» |
Раздела ключей в меню моделей и в окне /model нет: выбор ключа живёт в одном
месте. Меню моделей вместо этого не показывает те группы провайдеров, которые
оказались клонами маршрутов этого кольца, — иначе один и тот же список моделей
повторялся бы по разу на каждый ключ.
Зачем это нужно
Потому что обе поломки маршрута, который поддерживают вручную, — тихие. Модель,
которую провайдер добавил, не появляется, и никто не говорит почему: ошибки нет,
есть только отсутствие. Модель, которую провайдер снял, по-прежнему видна, и её
выбор даёт сетевую ошибку, которая выглядит как проблема со связью, а не как
устаревшая строка в YAML-файле.
И то и другое лечится чтением данных у провайдера вместо их переписывания. Список,
который записывает этот плагин, несёт настоящие размер окна контекста, лимит
вывода, входные модальности и уровни рассуждения, поэтому модель, получившая
поддержку изображений или новый уровень мышления, описана верно в тот же день, а не
наследует запасное значение, которое кто-то когда-то угадал.
А поскольку значки берутся из того же зеркала, совершенно новая модель приходит уже
с кешированным значком своего производителя: зеркало перечитывает каталог в тот
момент, когда эндпоинт называет модель, которой оно раньше не видело, — то есть
ровно тогда, когда выходит релиз.
Третья задача — про другую тишину. Ключ провайдера — это отдельный лимит и отдельный
учёт, а профиль один, поэтому «свой ключ на каждый чат» без плагина означает либо
второй профиль, либо вторую строку в YAML, которую кто-то должен поддерживать вручную.
Вручную поддержанный клон маршрута устаревает по тем же правилам, что и написанный
вручную список моделей, — в день релиза. Кольцо ключей пишет клоны само и тем же
проходом, которым записывается список моделей, поэтому маршрут ключа не отстаёт от
маршрута профиля, а чат, отправляющий запросы другим ключом, видит ровно тот же
выбор моделей, что и его сосед.
Ничего из этого не привязано к одному провайдеру. Сайт-источник, маршрут и строка
профиля — настройки; https://rustvy.xyz и rustvy являются значениями по
умолчанию.
Установка
dsh plugin --profile web add dsh-rustvy
Либо прямо с GitHub, если нужна самая свежая ревизия, а не опубликованная версия:
dsh plugin --profile web add github:koliy82/dsh-rustvy
Голый koliy82/dsh-rustvy без схемы не принимается: разбор источника установки
знает имя в реестре, git-спецификацию, tarball и абсолютный локальный путь, а
git-спецификации нужна схема либо сокращение github:.
Подтверждение сборки не запрашивается ни в том ни в другом случае: у пакета нет ни
install-скрипта, ни зависимостей, поэтому pnpm нечего собирать.
Из локальной копии этого репозитория:
pwsh -File .\install.ps1
install.ps1 копирует пакет в <профиль>\node_modules\dsh-rustvy как настоящий
каталог и дописывает имя в dsh.profile.bundles манифеста профиля. Работающий
harness подхватывает строку по изменению манифеста.
После этого один раз перезапустите harness или обновите страницу: значки и страница
настроек появляются на лету, а строка загрузчика читается на композиции.
Маршрут, в который пишется список моделей
Значки работают без всего остального. Половине со списком моделей нужен маршрут
@deepseek-ai/dsh-llm-pi-ai, в который можно писать. Добавьте в
cordis.patch.yml профиля — или отредактируйте в Настройки → Плагины →
Конфигурация плагинов:
- id: llm-pi-ai
name: "@deepseek-ai/dsh-llm-pi-ai"
config:
providers:
rustvy:
displayName: rustvy
apiKeyEnv: RUSTVY_API_KEY
api: openai-completions
baseURL: https://rustvy.xyz/v1
models:
- id: placeholder
name: placeholder
Список models обязан только разбираться: этот плагин его заменяет. Все остальные
поля при перегенерации переносятся без изменений, потому что переключатель
совместимости протокола или ссылка на учётные данные — это знание о вашем
развёртывании, которого нет ни в одном каталоге.
apiKeyEnv задаёт, откуда маршрут берёт ключ; он разрешается на каждый запрос через
механизм учётных данных harness — сначала унаследованное окружение процесса, затем
$DSH_HOME/.credentials.yaml, затем .env в каталоге запуска или в $DSH_HOME.
Сам ключ в конфигурационные файлы не попадает. Плагин при чтении списка моделей
подставляет ключ только тогда, когда окружение процесса уже содержит переменную с
таким именем; эндпоинт, под который он написан, отвечает и без ключа.
Чтобы сделать модель основной:
- id: agent-default-model
name: "@deepseek-ai/dsh-agent-default-model"
config:
provider: rustvy
model: claude-opus-5-5
reasoningEffort: high
Чистый профиль: маршрут создаётся сам
Всё выше — про профиль, в котором маршрут уже есть. На чистом DSH его нет, и раньше
плагин в этом случае не делал ничего и ничего не говорил: в списке моделей оставался
встроенный DeepSeek, ключа от которого нет, то есть не работало ничего. Теперь
хост-половина при монтировании проверяет, есть ли в строке llm-pi-ai провайдер
rustvy, и если его нет — создаёт: адрес, имя, apiKeyEnv, форму API и список
моделей, прочитанный у провайдера. Существующий маршрут не перезаписывается никогда и
ни в одном поле: displayName, apiKeyEnv, baseURL и models могли быть
исправлены руками, и это ваше знание о вашем развёртывании, которого нет ни в одном
каталоге.
DeepSeek не затрагивается никогда. Он живёт в других строках композиции
(llm-deepseek, llm-deepseek-account), а запись идёт только в строку llm-pi-ai, и в
ней — только в ключ rustvy, с сохранением чужих провайдеров той же строки. Это
закреплено тестом, который сравнивает строку llm-deepseek побайтово до и после, а не
рассуждением о том, что запись туда попасть не должна: требование пользователя
«deepseek вообще не трогал» абсолютное, и теперь, когда пишутся две строки вместо
одной, оно важнее, а не менее важно.
Ключ при установке не спрашивается. Это намеренное решение: создание маршрута не
должно ничего требовать, а маршрут без ключа — как раз то состояние, после которого
список моделей появляется. Ключ добавляется потом, кольцом ключей на той же странице
настроек или переменной RUSTVY_API_KEY. Поэтому нормальный вид сразу после установки
— «маршрут создан, ключ ещё не задан», и страница говорит именно это.
Модель по умолчанию переключается, только если вы её сами не выбирали. Условия два
и выполняются оба: маршрут отсутствовал и создан этим же монтированием, и у строки
agent-default-model нет переопределения в слое профиля. Второе — единственная
надёжная граница между чистой установкой и осознанным выбором: если модель когда-нибудь
выбирали вы, строка тронута не будет и композер останется на вашем выборе. Если
проверить выбор не удалось, плагин не пишет ничего — перезаписанную модель из него не
восстановить, а догадка здесь дороже бездействия.
Что говорит страница настроек. Исход создания маршрута — условная строка вверху
раздела Rustvy, над блоком аптайма, и рисуется она только когда есть что сказать:
| Исход | Строка |
|---|
| маршрут создан этим монтированием | «Маршрут rustvy создан этим монтированием: N моделей» и отдельно про ключ |
| маршрут уже был | ничего — кроме строки про ключ, если он не задан |
| создать не удалось | «Маршрут rustvy не создан: …» с причиной и следующим действием |
| модель по умолчанию переключена | «Модель по умолчанию переключена с X на Y — Z» |
| не переключена, потому что её выбирали вы | одна спокойная строка с объяснением |
| не переключена, потому что маршрут уже был | ничего |
Порядок разделов при этом не меняется: строка условная, а решение о порядке касалось
разделов. В установившемся состоянии не рисуется ничего, значит аптайм остаётся первым
ровно как поставлен; когда строка рисуется, установка либо сломана, либо только что
загружена, и это важнее порядка.
Кнопки на этой строке нет, и это решение, а не недоработка: запасной конец запускается
только при монтировании, отдельной конечной точки повтора нет, поэтому кнопка могла бы
лишь перечитать состояние, которое ничего не перезапускает, — получился бы элемент с
подписью «повторить», который не повторяет. Настоящее действие — перезагрузка окна, и
строка называет его текстом, по-разному для разных причин.
Про ключ страница утверждает ровно то, что знает. Хост спрашивает не окружение процесса,
а тот же механизм учётных данных, которым пользуется адаптер, и учитывает кольцо: ключ,
добавленный кольцом, делает провайдер рабочим, хотя RUSTVY_API_KEY в окружении пуста.
«Ключ ещё не задан» печатается только когда не разрешается ни то ни другое; когда хост
определить не смог, страница указывает оба места, ничего не утверждая. Две фразы
различаются ровно одним утверждением, и проверка в наборе не даёт им слиться: слияние
вернуло бы «ещё не задан» на машину, которая работает. Значение пересчитывается на
каждый запрос состояния, поэтому ключ, добавленный кольцом после загрузки страницы,
убирает строку без перезагрузки — иначе кольцо внизу показывало бы новый ключ, а строка
наверху утверждала бы, что ключа нет.
Отдельно страница говорит, откуда взят список моделей, если источник не ответил и
маршрут создан из локальной копии каталога: у такого списка неизвестная свежесть, и
перечитать его стоит, когда сеть появится. Кнопка «Обновить каталог и иконки» здесь не
лекарство — зеркало собирается с того же источника, и хост уже попытался обновить его
сам, пока создавал маршрут.
Как пользоваться
Откройте Настройки → Rustvy. Страница показывает, что держит зеркало, что отдаёт
провайдер и каково точное расхождение между ними. Кнопка «Обновить каталог и
иконки» освежает обе половины, «Записать список моделей» записывает
сгенерированный список. Без одного из этих нажатий не пишется ничего, если вы сами
не попросили об этом в настройках.
Раздел «API-ключи» на той же странице держит кольцо: кнопка «+» открывает
инлайн-форму «название + значение», у каждой строки ключа есть замена значения,
переименование, назначение ключом по умолчанию, проверка и удаление. Ключом по
умолчанию можно пользоваться, вообще не заходя в настройки: переключатель в панели
инструментов композера делает то же самое для того чата, который открыт.
Настройки
Настройки → Rustvy
Сверху вниз страница устроена так: выключатели и поля, затем блок «Аптайм моделей»,
«Модели провайдера», «Ключи на несколько чатов», «Сбор данных Rustvy» и в
самом низу «Зеркало каталога» с его адресами и файлами. Аптайм первый, потому что это
единственный блок, отвечающий на вопрос, с которым страницу открывают, — жива ли модель,
которую сейчас выберут; зеркало последнее, рядом с предупреждением о неудачном чтении
каталога, которого только оно и касается.
В блоке «Зеркало каталога», рядом с другими сетевыми фактами, живёт прокси плагина:
переключатель семейства «Тип прокси» с двумя положениями — HTTP(S) и SOCKS, —
поле адреса, выключатель, «Записать» и «Проверить прокси». Схемы в адресе нет: поле
принимает логин:пароль@хост:порт, а семейство выбирается только переключателем, поэтому
строка и переключатель не могут разойтись. HTTP(S) означает «прокси, говорящий по HTTP», и в
этом выпуске соединение с ним всегда открытое: автоматическое определение шифрования не
реализовано. Клиент уже готов напечатать фразу о транспорте, как только серверная половина
начнёт её присылать, — до тех пор она молчит, а не ставит прочерк. SOCKS
означает SOCKS5, а SOCKS4 отклоняется громко и текстом, указывающим на переключатель.
Семейство не выводится из адреса: наличие пароля не говорит ничего ни о шифровании
соединения с прокси — HTTP-прокси с паролем обычный случай, — ни о версии рукопожатия
SOCKS, где сервер SOCKS5 без аутентификации всё равно отвергнет приветствие SOCKS4. Смена
положения перестраивает транспорт даже при том же хост:порт, поэтому переключатель пишет
сразу, а не по «Записать». Строка состояния над ними
говорит одно из четырёх: установленный прокси показывается замаскированным адресом,
выключенный — словом «выключен», включённый, но не установившийся, — собственными словами
хоста в предупреждающем цвете, а у хоста, который поле не публикует, стоит прочерк. Само
поле — единственное место на странице, где виден адрес целиком, вместе с паролем: оно
обязано показывать ровно то, что записано, даже если строку не разобрать, потому что поле,
тихо поправившее значение, разошлось бы с файлом незаметно.
Поле не сохраняет по потере фокуса, в отличие от остальных полей страницы, и это не
непоследовательность: «Проверить прокси» — кнопка рядом с полем, а нажатие кнопки уводит
фокус, и сохранение по фокусу означало бы, что проверка сначала записывает адрес, а потом
сообщает, что ничего не записала. Пишет только «Записать». Проверка отвечает тремя разными
предложениями, а не двумя состояниями: любой ответ провайдера, включая 502 и 503,
означает, что прокси работает, потому что запрос через него прошёл, и статус здесь
информация, а не провал; отсутствием ответа считается только случай, когда ответа не было
вовсе, — отказ, таймаут, DNS; а если строку не удалось разобрать, запрос не отправлялся
совсем, и это ошибка в тексте, а не в прокси. Отдельно говорится, что не удалось выполнить
саму проверку, — это четвёртый случай, и он ведёт пользователя к его сети или к плагину, а
не к его тексту. Предложение называет, какой адрес проверялся: введённый, ещё не записанный,
или сохранённый, — потому что «прокси работает» про строку, которую пользователь не применил,
требует видимого подлежащего. Проверка ничего не сохраняет и не включает, и страница это
говорит, когда выключатель выключен: «проверка прошла» рядом с выключенным прокси — та самая
путаница, которую приносят как баг.
Значки:
| Параметр | По умолчанию | Смысл |
|---|
| Показывать иконки моделей | вкл | общий выключатель значков; в выключенном виде убирает все значки и не трогает список моделей |
| Иконка в кнопке выбранной модели | вкл | значок и в кнопке модели композера, а не только в строках меню; он же убирает с кнопки значок аптайма |
| Прятать логотипы других скинов | вкл | прятать собственную плашку скина в строке, где значок нарисован этим плагином |
| Показывать аптайм моделей | вкл | блок «Аптайм моделей» на этой странице и значок состояния в списке моделей и в кнопке выбранной модели; выключенный не читает зеркало коллектора вовсе |
| Размер значка, px | 16 | от 10 до 32 |
Список моделей:
| Параметр | По умолчанию | Смысл |
|---|
| Держать список моделей маршрута актуальным | вкл | общий выключатель; в выключенном виде YAML маршрута не трогается |
| Записывать список | при нажатии «Обновить» | только отдельной кнопкой — самостоятельно не пишет никогда · при нажатии «Обновить» — пишет по кнопке обновления · сам, при каждом обновлении — пишет и при фоновом суточном обновлении |
| Название модели | как на сайте провайдера | отображаемое имя провайдера либо сырой идентификатор |
| Предлагать уровень «без рассуждений» | вкл | добавлять уровень off рассуждающей модели, которая его не объявила |
| Оставлять модели, которые провайдер больше не отдаёт | выкл | сохранять установленную модель, которую эндпоинт больше не отдаёт, — на случай временно ошибочного списка, а не действительно снятой модели |
| Маршрут провайдера | rustvy | какой ключ внутри providers записывать |
| Строка профиля | llm-pi-ai | какая строка загрузчика его содержит |
| Сайт провайдера | https://rustvy.xyz | откуда читать каталог и логотипы |
Режим записи по умолчанию намеренно средний: нажатие кнопки — это решение человека,
а фоновое истечение срока — нет, и переписывать конфигурацию загрузчика работающего
профиля, когда никто не просил, — это то, на что стоит спросить разрешение.
Каждый адрес и файл, с которым работает плагин, виден на странице: сайт, страница
каталога и эндпоинт списка моделей — ссылками, открывающимися в новой вкладке, а
пути к зеркалу, настройкам, копии для отката и патчу профиля — текстом с кнопкой
копирования. Кнопка «Показать блок для ручной правки» выдаёт ровно тот YAML,
который получился бы при автоматической записи, так что желающий вставить его в
cordis.patch.yml сам не переписывает краткий пересказ.
У одного поля нет элемента управления, потому что оно принадлежит YAML профиля, а не
форме: modelZones (по умолчанию ["standard"]) — зоны каталога, которые
допускаются, когда эндпоинт недоступен и состав списка определяет зеркало.
Ключи:
| Параметр | По умолчанию | Смысл |
|---|
| Ключи на несколько чатов | вкл | общий выключатель кольца; в выключенном виде подмена маршрута не применяется и каждый запрос идёт маршрутом профиля |
Выключение ничего не удаляет: маршруты ключей остаются в конфигурации профиля,
значения — в хранилище учётных данных, выбор чатов — в своём файле, поэтому
включённая обратно возможность продолжает с того места, на котором её выключили.
Переключатель живёт в разделе «API-ключи», а не рядом с выключателями значков и
списка моделей: он принадлежит кольцу, и там же страница объясняет, почему
добавление ключа недоступно, если хост не отдаёт нужный сервис.
Сбор ошибок:
| Параметр | По умолчанию | Смысл |
|---|
| Отправлять успешные запросы | вкл | знаменатель процента аптайма. Успехи агрегируются в окно (по умолчанию 1 минута) и уходят одним отчётом, а не по одному на каждый запрос: агрегация убирает 21 % запросов против отправки по одному, а при прежнем окне в 15 минут убирала 86 % — окно стало сравнимо с промежутком между успехами, и задержка процента была ценой этой экономии. Пустое окно не отправляется вовсе, поэтому простаивающая машина не платит ничего — это ноль байт, а не округление. Выключение останавливает отправку, но не учёт: счётчик продолжает закрывать свёрнутые отказы и отвечать на вопрос, жив ли наблюдатель |
| Собирать ошибки rustvy | вкл | общий выключатель сбора: одна строка на инфраструктурный сбой провайдера уходит коллектору аптайма. Выключенный не отправляет ничего, и накопленная очередь остаётся на диске |
Оба выключателя живут в блоке «Сбор данных Rustvy» — успехи над ошибками, как и просил
пользователь, — потому что это единственные две настройки страницы, которые что-то
отправляют за пределы машины. Блок переименован из «Сбор ошибок Rustvy»: подпись,
обещающая только сбор ошибок, описывала бы содержимое ложно, а подпись читает пользователь,
чтобы решить, открывать ли блок. В блок настроек отображения наверху страницы эти
выключатели не перенесены: тот блок целиком про пиксели — значки, иконка в кнопке, размер,
— и настройка отправки данных среди них стояла бы рядом с параметрами, которые не меняют
ничего, кроме внешнего вида.
Под выключателем успехов — одно короткое пояснение о цене, потому что пользователь спросил
именно про трафик: сколько стоит один отчёт и сколько выходит за рабочий день. Числа в нём
замерены на настоящем документе, а не взяты из оценки, и идут вместе с условиями замера;
рядом появляется собственный итог этой установки — сколько окон и байт ушло с начала
работы, — когда хост их публикует. Числа, которого состояние не содержит, страница не
рисует вовсе: выдуманная оценка трафика на экране хуже отсутствия оценки. Кнопок отправки
или повтора в блоке нет и не будет: окно сбрасывается собственным таймером, отдельной
конечной точки для досрочного сброса нет, поэтому кнопка «отправить сейчас» могла бы лишь
перечитать состояние, которое ничего не перезапускает, — элемент с подписью «повторить»,
который не повторяет. Отдельной строкой, и только когда счётчик ненулевой, блок сообщает
«N окон потеряно» — сколько окон успехов сформировалось на этой установке и не дошло до
коллектора; нулевая потеря не рисуется, потому что строка, пустая у большинства установок
всю жизнь, учит не читать блок. Что именно уходит, что не уходит никогда и когда сбор срабатывает,
описано в разделе
«Сбор ошибок и аптайм» ниже и в docs/error-report.md —
раскрытие не исчезло вместе с текстом на странице, оно переехало туда, где его можно
прочитать целиком. Состояние сбора — адрес коллектора, анонимный
идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя
ошибка отправки и разбивка сбоев по видам — хост-половина по-прежнему отдаёт на своих
петлевых маршрутах /dsh-rustvy/state и /dsh-rustvy/errors, и смотреть его нужно там,
а не в интерфейсе. Адрес — uptimeEndpoint, по умолчанию
http://185.75.189.247:3789/api/report — поля ввода не имеет: его перенаправляют без
выпуска новой версии переменной окружения DSH_RUSTVY_UPTIME_ENDPOINT или правкой
<профиль>\.dsh-rustvy\config.json руками.
Настройки хранятся в <профиль>\.dsh-rustvy\config.json. Пять переменных окружения
перекрывают их — для развёртывания, которое направляет зеркало и сборщик в другое место
либо хочет смонтировать плагин неработающим, молчащим или без панели аптайма:
DSH_RUSTVY_ORIGIN и DSH_RUSTVY_DISABLED=1, DSH_RUSTVY_UPTIME_ENDPOINT,
DSH_RUSTVY_NO_ERRORS=1 и DSH_RUSTVY_NO_UPTIME=1.
Первая перенаправляет сайт провайдера, вторая выключает плагин целиком. Третья
перенаправляет коллектор аптайма и переопределяет и значение по умолчанию, и
config.json: принимается только адрес, разбирающийся как http/https, концевые
слеши обрезаются, а непригодное значение игнорируется, и тогда остаётся действующая
настройка. Четвёртая и пятая, каждая в значении 1 или true, выключают независимо от
настройки сбор ошибок и вид аптайма — тот же приём, которым DSH_RUSTVY_DISABLED гасит
значки.
Откуда берутся картинки
В сам пакет ничего не вшито. Хост-половина зеркалит два публичных объекта с сайта
провайдера в профиль, а браузерная половина читает их с локального адреса:
- каталог моделей — сайт рисует свою таблицу моделей из JSON-острова в
собственной странице (
<script id="rustvy-catalog">), поэтому
неаутентифицированный читатель получает ровно те данные, которые видит панель.
Зеркалится в <профиль>\.dsh-rustvy\catalog.json.
- файлы логотипов (
/logos/<имя>.svg) — копируются в
<профиль>\.dsh-rustvy\logos\ при первом обращении.
Оба читаются по собственному исходящему маршруту процесса (lib/net.js): сначала
живой глобальный диспетчер, чтобы прокси, установленный dsh-proxy-gui или любым
другим маршрутом процесса, соблюдался, затем соединитель, построенный здесь из
HTTPS_PROXY / ALL_PROXY / HTTP_PROXY, — на случай, когда диспетчер не
установлен. NO_PROXY учитывается в обоих случаях. Сама страница наружу не выходит:
браузер спрашивает значок у 127.0.0.1, и именно поэтому всё работает на машине,
чей браузер до источника не достаёт вовсе.
Каталог обновляется раз в 24 часа и по требованию; холодное зеркало ожидается до
6 секунд, а устаревшее обновляется в фоне, пока строки продолжают показывать
имеющиеся значки. Когда источник недоступен, кеш на диске по-прежнему отдаёт всё,
что в нём есть.
Откуда берётся список моделей
Два источника в таком порядке, потому что отвечают они на разные вопросы:
- собственный эндпоинт маршрута (
GET {baseURL}/models) — авторитетный список
состава: ровно те модели, которые можно назвать в запросе, каждая со своими
context_k, max_output_tokens, modalities и reasoning_levels. Это тот же
эндпоинт, который опрашивает собственное обнаружение адаптера.
- зеркало острова каталога — названия и запасной вариант. Это единственный
источник, несущий отображаемое имя, производителя и имя файла логотипа, поэтому
он соединяется с отдаваемым списком по идентификатору. Когда эндпоинт не
отвечает, состав определяет сам остров по своим полям
enabled и zone, так что
маршрут остаётся актуальным и во время сбоя, а не застывает на последнем удачном
чтении. На здоровом источнике оба списка совпадают точно.
Модель, которую эндпоинт отдаёт, а зеркало ни разу не видело, — признак того, что
остров отстал от боевого API, а это происходит ровно в момент релиза, поэтому тогда
остров перечитывается; ожидание ограничено тем же коротким сроком, что и для
холодного зеркала.
Что именно записывается
Каждая сгенерированная запись несёт только те поля, которые объявлены в схеме
конфигурации адаптера, потому что список models профиля маршрута заменяет
каталог маршрута, а неизвестное поле в лучшем случае будет отброшено:
| Поле | Откуда берётся |
|---|
id | идентификатор отдаваемой модели — то, что нужно назвать в запросе |
name | отображаемое имя из острова либо идентификатор, если имени нет |
contextWindow | context_k × 1000 |
maxTokens | max_output_tokens |
input | modalities, отфильтрованные до text/image |
reasoningEfforts | reasoning_levels, отображённые на уровни адаптера, либо false для нерассуждающей модели |
compat | не генерируется никогда — существующее всегда переносится |
Поле, о котором никто не высказался, пропускается, а не заполняется значением по
умолчанию, чтобы запасное значение самого адаптера оставалось в силе и написанное
вручную не закреплялось молча. compat — единственное, что переносится без
изменений: переключатель совместимости протокола это знание о шлюзе, которого нет
ни в одном каталоге.
Уровни рассуждения отображаются на словарь адаптера (off, minimal, low,
medium, high, xhigh, max); неизвестный ему уровень отбрасывается и сообщается
примечанием, а не отказом всей записи. Необъявленные уровни адаптер не
поддерживает, а объявленный off без боевого значения означает «предлагать, ничего
не отправляя» — поэтому этот переключатель включён по умолчанию.
Что запись отклоняет
Пустой список не записывается никогда: единственный способ его получить — неудачное
чтение, а маршрут без моделей не обслуживает ничего. Актуальный список тоже не
записывается, потому что configEditor.edit() согласует весь загрузчик, и тратить
одно согласование на отсутствие изменений означало бы перезагрузить работающий
профиль впустую. Актуальным при этом считается не только маршрут профиля: запись
происходит и тогда, когда сам он в порядке, а список моделей одного из клонов
кольца ключей отстал, — иначе чат, отправляющий запросы другим ключом, видел бы
меньше моделей, чем его сосед. Каждая запись сохраняет предыдущий документ в
<профиль>\.dsh-rustvy\models-backup.json, а кнопка «Откатить к предыдущему»
возвращает его обратно; состояние, которое откат вытесняет, само становится
следующей копией, поэтому откат тоже можно откатить.
Как строка сопоставляется со значком
Строки сопоставляются по видимому названию в таком порядке:
- идентификатор модели из зеркального каталога (
claude-opus-5-5,
abyss/glm-5.3, …);
- её отображаемое имя (
OPUS 5.5);
- идентификатор, с которого название начинается, — скин может оставить в том же
элементе собственную строку описания, поэтому название часто является префиксом,
а не всей строкой; побеждает самый длинный подходящий идентификатор, чтобы
claude-opus-5-5 никогда не достался claude-opus-5;
- форма идентификатора — для модели, которой зеркало ни разу не видело:
claude-* → Anthropic, gpt-*/o1…o9 → OpenAI, glm-* → Zhipu,
kimi-*/k3 → Moonshot, qwen-* → Alibaba Qwen, deepseek-* → DeepSeek,
ahmia-* → Ahmia, mimo-* → Xiaomi, fable-* → Anthropic, gemini-* →
Google, grok-* → xAI.
Модель, не подошедшая ни под что, не получает значка вовсе: выдуманная иконка была
бы неправильным ответом, а не отсутствующим. Модель, которая подошла, но чей логотип
зеркало получить не смогло, получает вместо пустого квадрата плашку с монограммой —
первыми буквами производителя: маска с неработающим URL закрасилась бы сплошным
квадратом.
Строки уровней усилия, заголовки производителей, корневые ячейки меню, поле поиска и
строки о сбое каталога не подходят ни под одно правило и остаются без значка.
Кольцо API-ключей
Несколько ключей провайдера в одном профиле, и каждый чат отправляет запросы тем,
который выбрал его пользователь. Выбор живёт в панели инструментов композера —
рядом со списком режимов доступа, а не в меню моделей, — действует на один чат и
переживает перезапуск. Учётная запись DeepSeek не затрагивается ни в одном из мест
этого раздела.
Почему один ключ — это один маршрут
@deepseek-ai/dsh-llm-pi-ai берёт ключ из profile.apiKeyEnv — это имя, а не
значение, — и разрешает его через механизм учётных данных harness на каждый запрос,
ничего не кешируя. Поля для ключа нет ни в конфигурации вызова (LlmCallConfig), ни
в параметрах генерации (GenerateOptions), а baseURL и заголовки запекаются в
снапшот профиля. Передать адаптеру ключ «в этом запросе» нечем, поэтому единственный
честный способ дать чату другой ключ — другой маршрут: клон с теми же api,
baseURL и models и своим apiKeyEnv.
Из этого следует всё устройство. Добавление ключа пишет значение в хранилище учётных
данных под новым именем, клонирует маршрут профиля через ctx.configEditor и
запоминает запись в кольце. Выбор чата применяется на водопаде agent/request
подменой имени маршрута в конфигурации вызова — тем же приёмом и на том же шве,
которым harness применяет выбор модели для отдельного чата, поэтому он работает
ровно там, где уже работает выбор модели, и не заводит собственного пути к запросу.
| Ключ | Имя учётных данных | Маршрут | Отображаемое имя маршрута |
|---|
| первый (основной) | RUSTVY_API_KEY — как объявлен в профиле | rustvy — как объявлен в профиле | rustvy |
| второй | RUSTVY_API_KEY_2 | rustvy-k2 | rustvy · <название ключа> |
| n-й | RUSTVY_API_KEY_<n> | rustvy-k<n> | rustvy · <название ключа> |
Имена строятся от маршрута из настроек, а не зашиты: при modelRoute: rustvy второй
ключ получает rustvy-k2, при другом имени маршрута — свои суффиксы. Клон копирует
профиль маршрута целиком, меняя только apiKeyEnv и отображаемое имя, поэтому
написанный вручную переключатель совместимости протокола и всё остальное знание о
своём шлюзе переходит в каждый ключ, а не теряется при первом же добавлении.
Предел — 12 ключей: это кольцо, а не реестр. Новый ключ берёт первый порядковый
номер, у которого свободны и имя учётных данных, и маршрут, поэтому имя удалённого
ключа не переиспользуется, а чужая строка, случайно оказавшаяся на таком имени, не
перезаписывается.
Основной ключ
Первый ключ кольца — не запись, которую кто-то добавил, а маршрут, уже объявленный в
профиле: он наследует его apiKeyEnv и его имя и основным остаётся всегда. Профиль,
который работал до этой возможности, продолжает работать без изменений — кольцо с
одним ключом не переименовывает ничего и никуда не пишет. Удалить основной ключ
нельзя: он принадлежит маршруту профиля, и его удаление сломало бы провайдера для
каждого чата, включая те, которые кольцом ни разу не воспользовались. Отказ так и
объясняет и предлагает заменить значение — эта операция доступна любому ключу и
конфигурации не касается, потому что имя учётных данных при ней не меняется.
Где что лежит
| Что | Где |
|---|
| Значения ключей | $DSH_HOME\.credentials.yaml, раздел refs — то же хранилище, в которое пишет страница Models harness и из которого читает адаптер |
| Метаданные кольца | <профиль>\.dsh-rustvy\keys.json — id, название, имя учётных данных, маршрут, признак основного, порядковый номер, дата создания и defaultKeyId |
| Выбор чатов | <профиль>\.dsh-rustvy\session-keys.json — sessionId → keyId |
| Анонимный идентификатор установки | <профиль>\.dsh-rustvy\install.json — случайный UUID и дата создания; принадлежит сбору ошибок, а не кольцу, и лежит рядом с остальными файлами плагина |
| Неотправленные отчёты об ошибках | <профиль>\.dsh-rustvy\errors-queue.json — очередь отчётов, которые коллектор аптайма ещё не принял |
| Последний вид монитора | <профиль>\.dsh-rustvy\uptime.json — кешированный ответ коллектора, который панель показывает с пометкой stale, когда коллектор недоступен; принадлежит виду аптайма, а не кольцу |
В файлах плагина значения ключа нет никогда: ни в keys.json, ни в
session-keys.json, ни в настройках, ни в копии профиля для отката, ни в очереди
отчётов об ошибках. Loopback-маршруты
отвечают маской — первые три и последние два символа — и восьмисимвольным отпечатком
SHA-256; этого достаточно, чтобы узнать ключ в списке и заметить две записи с одним
значением, и недостаточно, чтобы им воспользоваться. Само значение приходит в теле
единственного запроса, который его добавляет или заменяет, и никогда в строке
запроса: URL пишется в журналы, попадает в кеш и в историю браузера, а секрет — не
дело ни того, ни другого, ни третьего.
Выбор хранится на диске, а не в журнале сессии: это предпочтение маршрутизации
самого плагина, и плагин, пишущий собственные события в долговременный журнал,
оставил бы их в каждой сессии, где когда-либо был смонтирован. В файле лежат только
отклонения от ключа по умолчанию, поэтому чат, созданный до смены ключа по
умолчанию, следует за новым, а не держится старого.
Что видно в интерфейсе
Две поверхности: одна управляет кольцом, другая выбирает ключ для своего чата.
Переключатель появляется только тогда, когда выбирать есть из чего: при одном ключе
его нет вовсе, потому что один ключ — это не выбор.
Настройки → Rustvy → «API-ключи». Список ключей: название, маска, имя учётных
данных, маршрут, сколько моделей отдаёт этот маршрут и сколько чатов выбрали этот
ключ. Кнопка «+» в заголовке раздела открывает инлайн-форму «название +
значение» со скрытым полем и показом по желанию. У каждой строки — замена значения,
переименование (сохраняется по потере фокуса), назначение ключом по умолчанию,
проверка и удаление в два шага; у основного ключа кнопки удаления нет, и строка
объясняет почему. Ниже — блок «Маршруты ключей»: строка профиля и пакет, адрес
списка, счётчик «запросов через подмену», число чатов со своим выбором, списки
отсутствующих и отставших маршрутов, пути к обоим файлам с кнопкой копирования и
кнопка «Починить маршруты», которая появляется только тогда, когда маршрут
какого-то ключа действительно пропал.
Композер. Компактная кнопка в панели инструментов, сразу за списком режимов
доступа, — регистрация в слоте conversation.input.left: название активного ключа
либо «ключ по умолчанию», если у чата нет собственного выбора. В меню — по строке на
ключ с галочкой на активном, строка «По умолчанию» и строка «+ Добавить ключ» с
той же инлайн-формой; ключ, добавленный из чата, сразу выбирается для этого чата,
потому что добавляют его из разговора именно затем, чтобы отправлять запросы им.
Кнопка не рисуется, пока слот не передал sessionId: выбор принадлежит чату, и
переключатель, который сохранит его не тому чату, хуже отсутствия переключателя.
Меню выбора модели и всплывающее окно /model. Раздела ключей здесь нет — ни
строк, ни заголовка: выбор живёт в композере, и второй такой же список в меню только
удваивал бы место, где можно ошибиться. Декоратор делает здесь обратное — прячет
группы провайдеров, которые хост назвал в hiddenGroups, то есть клоны маршрутов
этого кольца, чтобы один и тот же список моделей не показывался по разу на каждый
ключ. Не объявить их хост не может: клону нужен собственный список моделей, иначе
после подмены маршрута запрос не найдёт в нём свою модель. Скрытие структурное и
осторожное: заголовок группы опознаётся по форме, а не по имени класса, потому что
классы хозяйских примитивов этому плагину не видны и между версиями не стабильны, а
прячется либо наименьший содержащий группу элемент, либо заголовок вместе с
идущими за ним строками до следующего заголовка. Если уверенности нет, не прячется
ничего: по ошибке спрятанная строка модели — это отнятый выбор, а лишний раз
показанная группа — только повтор.
Что происходит при добавлении ключа
Порядок операций намеренный. Сначала значение пишется в хранилище учётных данных:
маршрут, чьё имя ни во что не разрешается, роняет каждый запрос, который его назвал.
Затем идёт запись конфигурации, и если она не удалась, значение забирается обратно,
поэтому неудачное добавление не оставляет после себя ничего. Документ кольца пишется
последним, когда существуют и значение, и маршрут.
Запись конфигурации — настоящая и поддерживаемая: она идёт через ctx.configEditor,
который согласует профиль, и ровно то же самое делает страница Models harness, когда
в профиль добавляют провайдер. configEditor.edit() согласует весь загрузчик,
поэтому записи кольца выстроены в очередь: два ключа, добавленные одновременно, —
это две последовательные правки, а не две правки против документа, который одна
другая только что заменила. И когда карта маршрутов уже соответствует кольцу,
запись не происходит вовсе — тратить согласование на отсутствие изменений этот
плагин отказывается и в списке моделей.
Ключ можно проверить, не отправляя им запрос: проверка делает GET {baseURL}/models
с этим ключом и сообщает статус, число отданных моделей и текст ошибки провайдера как
он пришёл.
Насколько узка подмена
Подмена меняет ровно одно поле конфигурации вызова — имя маршрута — и только когда
выполнено всё перечисленное:
- возможность включена настройкой
apiKeys;
- запрос пришёл с идентификатором сессии — без него выбирать не для кого;
- в кольце два ключа или больше;
- ключ этого чата — его собственный выбор, а при его отсутствии ключ по умолчанию —
не основной;
- запрос и без того шёл одним из маршрутов этого кольца (
rustvy, rustvy-k*);
- целевой маршрут установлен в профиле;
- целевой маршрут отдаёт запрашиваемую модель.
Во всех остальных случаях конфигурация возвращается как пришла. deepseek-account и
любой другой провайдер — второй шлюз, соседняя строка, чужая запись с ключом — не
переименовываются никогда: условие пятое проверяет принадлежность маршрута кольцу, а
не его похожесть.
Клон, который пропал или не отдаёт нужную модель, не роняет шаг: запрос уходит
маршрутом из профиля, а причина записывается. Она видна в поле fallback на
GET /keys — страница настроек рисует её предупреждением над списком ключей — и
строкой в журнале, которая не повторяется, пока причина не изменится. Логика здесь
такая: неработающий маршрут ключа — это проблема конфигурации, и правильный ответ на
неё — состоявшийся запрос плюс видимая причина, а не упавший шаг, о котором не
сказано почему. Исключение внутри самой подмены перехватывается, и запрос идёт как
шёл.
Список моделей пишется одним проходом на все маршруты кольца — базовый и каждый
клон, который кольцо видит в профиле, — поэтому клон не может отстать от базового
маршрута, а блок для ручной правки (/models.yaml) содержит клоны вместе с их
именами учётных данных, чтобы желающий вставить строку в cordis.patch.yml сам не
реконструировал их по описанию.
Два счётчика на GET /keys отвечают на вопрос, который иначе выглядел бы как
«ключи не работают»: routing.seen — сколько запросов harness вообще провёл через
водопад agent/request, routing.applied — сколько из них были переименованы на
маршрут ключа. Ноль при живом трафике означает, что водопад не доходит до плагина,
и это видно сразу.
Сбор ошибок и аптайм
Монитор существует потому, что «сайт провайдера лежит» — это знание, которое сегодня
есть только у тех, кто в него упёрся, и у каждого из них по отдельности. Сбой запроса
выглядит как локальная проблема: человек повторяет шаг, переключает модель или уходит,
и никто — включая самого провайдера — не узнаёт, что в этот же момент upstream_502
ловили ещё четыреста установок.
Собирать это опросом нельзя. Проверочный запрос — это настоящий запрос к модели, он
стоит токенов того, чьим ключом отправлен, и одна метка «живо» на каждой модели раз в
минуту сжигает ровно то, за что пользователь платит. Поэтому аптайм здесь выводится из
пассивных отчётов об ошибках: запрос, который и так состоялся, либо удался, либо
сломался, хост-половине известен любой исход, но наружу уходит только сбой.
Дополнительных запросов нет вовсе, а монитор судит о здоровье модели по отсутствию
ошибок в окне — и знает то, чего опрос не покажет никогда: сколько людей задел сбой.
Что уходит в одном отчёте
Один отчёт — один запрос: плоский JSON-объект, без конверта и без массива событий.
Коллектор принимает его на POST /api/report; meta — единственный объект в нём, а всё
остальное лежит на верхнем уровне, потому что таких отчётов хранится много и читаются они
обратно сгруппированными по модели и часу.
Адрес по умолчанию — http://185.75.189.247:3789/api/report: публичный коллектор
монитора, пока намеренно на голом HTTP и по голому IP, — и он меняется без
выпуска новой версии, полем uptimeEndpoint в <профиль>\.dsh-rustvy\config.json или
переменной окружения DSH_RUSTVY_UPTIME_ENDPOINT.
| Поле запроса | Что несёт |
|---|
plugin | dsh-rustvy |
version | версия пакета |
provider | имя маршрута, которым шёл запрос |
model | идентификатор модели |
code | вид сбоя из собственного словаря плагина: upstream_502, connect_timeout, dns, … |
status | HTTP-статус, и только когда он есть: без соединения поля нет вовсе, а не 0 в нём |
message | собственный текст ошибки провайдера, очищенный и обрезанный до 2000 символов |
ts | момент сбоя в миллисекундах; коллектор в любом случае ставит и собственный serverTs |
meta | объект не больше 8 КБ: id — анонимный идентификатор установки, ms — длительность попытки, n — сколько событий свёрнуто (только когда больше одного), harnessCode — вердикт harness таким, каким он его назвал, и t |
Каждому понятию соответствует ровно один псевдоним из тех, что принимает коллектор:
code, а не type и не kind; message, а не error и не detail; ts, а не
timestamp. Анонимный идентификатор живёт в meta.id — отдельного поля для него у API
нет, а без него монитор не ответил бы на вопрос «у скольких людей».
Не уходит никогда: промпты, содержание сообщений и чатов, содержимое и пути файлов,
API-ключи, идентификатор сессии и вообще всё, что называет пользователя или его машину.
Это устройство, а не обещание: в отчёте нет ни одного поля, куда такое можно было бы
положить, а единственное свободное — message — проходит через очистку, стирающую ключи
вида sk-…, Bearer-токены, заголовки учётных данных, именованные секреты, пути Windows
(целиком, вместе с пробелами) и POSIX — они заменяются на <path> — и длинные
непрозрачные строки.
Какие сбои считаются аварией
Правило одно: сообщается о том, что сломалось у провайдера, и никогда о том, что
произошло у пользователя. Разбор идёт по коду harness, по статусу и по тексту
сообщения, и своя ситуация проверяется первой — текст про исчерпанную квоту не
становится аварией, даже если в нём встретилось число, похожее на статус.
| Считается аварией | Не считается |
|---|
upstream_502, upstream_503, upstream_400, upstream_404 — любой upstream_*, кроме 401, 402, 403 и 429 | закончившаяся подписка, заблокированный счёт, исчерпанная квота или кредиты |
http_5xx, http_404, http_408 | ограничение частоты запросов, включая 429 |
dns, refused, unreachable, tls | неверный, отсутствующий или отозванный ключ |
connect_timeout, timeout, reset, server, transport | превышенное окно контекста, неизвестная модель, неверный запрос |
| остановка, нажатая самим пользователем |
upstream_400 и upstream_404 сообщаются, а голые 400 и 404 — нет: префикс
означает, что шлюз провайдера сам назвал ответ своего источника, то есть сломалось там,
а не в запросе, который составили вы.
Успехи уходят окнами
Коллектор получает не только сбои: успехи тоже уходят, но не по одному, а
агрегированными в пятнадцатиминутное окно — один документ на окно на маршрут, со
счётчиком по каждой модели. Установка, которая сделала 230 запросов за сутки,
отправляет около 32 небольших документов, а не 230; установка, которая не работала, не
отправляет ничего вовсе. Замеренный объём — 534 байта на документ при двенадцати
моделях и около 20 КиБ в сутки вместе с заголовками HTTP; таблица, сценарии и способ
воспроизвести замер лежат в docs/error-report.md.
Локальную работу успехи делают по-прежнему: немедленно отправить ещё сворачивающийся
сбой этой модели, как только запрос снова прошёл. Отчёт попадает в коллектор в момент
восстановления, а не в конце окна сворачивания, и промежуток между двумя метками
времени и есть длина аварии.
Аптайм на мониторе поэтому настоящее отношение — ok / (ok + fails), а не отсутствие
ошибок в окне. Модель, которую никто не спрашивал, больше не выглядит так же, как
модель, которая работает: у неё нет знаменателя, и вместо процента рисуется прочерк.
Прочерк, ноль и число — три разных утверждения, и смешать любые два из них значит
соврать на экране.
Анонимный идентификатор и два файла
Идентификатор — случайный UUID и ничего больше: ни аппаратного идентификатора, ни имени
учётной записи, ни пути. Он нужен, чтобы считать людей, а не запросы: тысяча отчётов из
одной установки — это один человек, повторяющий попытку, а тысяча отчётов из трёхсот
установок — это авария. В отчёте он лежит в meta.id. Сбросить его — удалить
<профиль>\.dsh-rustvy\install.json: следующая отправка создаст новый, и установка
станет для монитора другой.
| Что | Где |
|---|
| Анонимный идентификатор | <профиль>\.dsh-rustvy\install.json |
| Неотправленные отчёты | <профиль>\.dsh-rustvy\errors-queue.json |
Очередь на диске — часть смысла, а не кеш: отчёт, который не удалось доставить,
остаётся в файле со своей настоящей меткой времени и уходит, когда сайт отвечает,
поэтому авария попадает в монитор с временами, в которые она произошла, а не с моментом
восстановления связи. Записывается она не чаще раза в 5 секунд и ещё раз при выгрузке,
поэтому перезапуск во время аварии ничего не теряет.
Сворачивание и пределы
Без них установка в циклическом повторе отправила бы коллектору тысячи отчётов об одной
и той же аварии:
| Что | Значение |
|---|
| Окно сворачивания сбоев | 60 секунд на пару «модель + вид»: ts остаётся временем первого сбоя, meta.ms берётся от последнего, число событий растёт в meta.n |
| Отправка очереди | раз в 15 секунд, до 10 отчётов за одну отправку — и каждый своим запросом, потому что одного запроса на отчёт требует сам коллектор |
| Предел в минуту | 20 отчётов на установку — прямой ответ на лимит коллектора в 60 отчётов в минуту на IP |
| Предел на установку | 1200 отчётов в час, выведенный из минутного (20 × 60); сверх него отправка останавливается до конца часа, а очередь сохраняется и уходит после его начала — отбрасываются только окна успехов, потому что их негде хранить |
| Потолок очереди | 500 отчётов; при переполнении отбрасываются самые старые |
| Повтор после отказа | 15 секунд, удваивается до 10 минут |
| Срок одной отправки | 10 секунд |
| Запись очереди на диск | не чаще раза в 5 секунд и ещё раз при выгрузке |
Ответы коллектора разбираются по смыслу, а не одним правилом на все 4xx. 200 { ok: true }
— отчёт принят. 400 означает, что в отчёте не оказалось ни message, ни code: такой
отчёт отбрасывается как непригодный, потому что коллектор, отказавший ему один раз,
откажет и снова. 429 и любой 5xx — в том числе 503, когда недоступно хранилище, —
оставляют отчёт в очереди и откладывают следующую попытку; заголовок Retry-After в
ответе 429 принимается как нижняя граница отсрочки. Сетевая ошибка оставляет отчёт в
очереди точно так же — именно поэтому авария доходит до коллектора уже после того, как
сайт ответил, и доходит с исходными метками времени.
429 отделён от 400 сознательно: это «занято», а не «запись плохая», и отчёт,
отброшенный на чужом лимите, был бы потерянным настоящим сбоем. Лимит коллектора — 60
отчётов в минуту на IP, и наш поток ниже него по построению, а не по договорённости:
одинаковые сбои одной модели сворачиваются в один отчёт за 60 секунд, одна отправка
уносит не больше 10 отчётов, минутный предел на установку — 20 против их 60, почасовой —
1200, а очередь выше 500 отчётов не растёт. То есть не этот плагин приводит кого-то к
429.
Адрес коллектора у всех установок один, поэтому минутный предел защищает ещё и соседей:
авария на одной машине не съедает долю в 60 отчётов в минуту, которая у общего NAT одна на
всех.
Как выключить
Выключателем «Собирать ошибки rustvy» в Настройки → Rustvy,
collectErrors: false в <профиль>\.dsh-rustvy\config.json либо переменной окружения
DSH_RUSTVY_NO_ERRORS=1, которая вынуждает молчание независимо от настройки: она для
развёртывания, которому нужен смонтированный, но молчащий плагин и которое не хочет
править файл профиля. Выключенный сбор не отправляет ничего и ничего нового в очередь не
ставит; на сами запросы к провайдеру он не влияет ни в каком состоянии. Форма отчёта,
словарь видов и контракт коллектора описаны в
docs/error-report.md.
Аптайм моделей
Тот же коллектор, который принимает отчёты об ошибках, отдаёт обратно вид монитора:
какие модели отвечают, у скольких людей и как давно они не отвечали. Плагин показывает
этот вид прямо в DSH — блоком на странице настроек и значком аптайма в списке моделей, —
чтобы выбор модели не требовал открывать браузер и искать там строку, которая и так видна
здесь.
Числа в блоке — собственные числа коллектора. Плагин ничего не пересчитывает: оценка,
посчитанная локально, разошлась бы со страницей монитора, а два ответа на вопрос «модель
жива?» хуже одного.
Процент вернулся — вместе со знаменателем
Правило «процента больше нет» было верным для данных, которые были, и отменено вместе с
появлением данных, а не вопреки им. Пока плагин сообщал только об ошибках, у процента
доступности не было знаменателя в принципе: число, которое рисовалось на значке, было долей
корзин без ошибок, и у него три плохих свойства — оно менялось вместе с окном при одном и
том же потоке событий, насыщалось (47 ошибок в корзине и одна ошибка давали одинаковый
вклад) и не различало широту (сбой у одного человека и авария у провайдера выглядели
одинаково).
Теперь successes собираются отдельной настройкой, и процент настоящий:
$$\text{аптайм} = \frac{ok}{ok + fails} \times 100$$
где ok — успешные запросы за окно, а fails — сумма счётчика повторов n по всем
отказам. Отказ, свёрнутый из трёх попыток в одну запись, даёт fails: 3, а не 1, и
процент считается от трёх: свёртывание экономит место в хранилище, а не уменьшает аварию.
Правило нулевой способности. Если ok + fails == 0, процента нет, и поле равно null,
а не 100 и не 0. «100%» про окно, в котором не произошло ничего, — утверждение, которое
никто не проверял; «0%» про то же окно — клевета. Страница рисует в этом случае прочерк.
Три утверждения обязаны различаться на экране, и это закреплено проверкой: число,
прочерк и ноль — три разные строки, а не одно значение и два отсутствия. Окно только
с успехами честно даёт 100, окно только с отказами честно даёт 0 — и именно этот
случай прежняя схема не могла показать вообще.
Процент старого коллектора не рисуется. Коллектор старше v3 тоже присылал поле
uptime, но считал его без единого успешного запроса в знаменателе — то есть ровно то
число, которое предыдущая версия правила сняла с экрана. Оно не возвращается только
потому, что имя поля вернулось: процент разрешает версия контракта, а не сам процент
и не соседние поля. Нормализатор плагина рисует uptime только тогда, когда приёмник
опубликовал contractVersion числом и не меньше 3, и рисует прочерк в противном случае.
Угадывать версию по наличию fails или ok нельзя, хотя соблазн понятен: эти поля
появились в v3. Такой вывод привязывает смысл поля к тому, что оказалось заполнено, а не
к тому, что производитель имел в виду; он не отличает v3 от будущего v4, который
переопределит uptime ещё раз; и он заставляет каждого потребителя заново выводить факт,
который производитель уже знает и может сообщить одним целым числом. Полное обоснование,
включая то, чего этот аргумент не утверждает, — в docs/uptime-view.md.
Прочерк не объясняется причиной. uptime: null — это факт о агрегате коллектора по
всем установкам: знаменателя не было у него, а не у вас. Признак collectSuccesses вашей
установки — один из возможных вкладов, а не причина, поэтому страница не пишет «процент не
считается, потому что успехи выключены». В сети, где отчитываются другие установки, такое
предложение делало бы причинное утверждение о глобальном значении на основании локального
признака. Прочерк рисуется прочерком и ничем больше, а утверждение «эта установка не
участвует в подсчёте» живёт в пояснении к собственному выключателю — потому что про свою
установку страница знает правду.
Доля «затронуто N% установок» осталась на экране и означает по-прежнему охват, а не
доступность: различные установки, сообщившие об ошибках в окне, к различным установкам,
присылавшим что-либо за 30 дней. Теперь рядом с ней настоящий процент, и подписи у двух
величин разные, потому что одно число про трафик, а другое — про то, насколько широко
известны отказы. Аптаймом охват не называется нигде.
Заголовок панели считает сбои, а не отчёты. «Сбоев за период» — это fails, сумма n,
то есть настоящие отказы; прежнее «ошибок за период» было числом записей и на живых данных
занижало на четверть (21 опубликованная запись против 28 настоящих сбоев). Подписи двух
величин различаются там, где видны оба числа: «сбоев» для отказов и «ошибок» для записей,
потому что одно слово над двумя величинами — это два утверждения под одним именем. Правило
состояния при этом по-прежнему читает записи, и подмена не может ему противоречить: n
никогда не бывает нулём и его отсутствие читается как единица, значит записей нет тогда и
только тогда, когда нет сбоев.
Откуда читаются
Хост-половина зеркалит ответ коллектора на свой петлевой маршрут
GET /dsh-rustvy/uptime.json?range=1h|24h|7d|30d, и панель читает зеркало, а не коллектор
напрямую: запрос остаётся в пределах машины, а зеркало держит последний удачный ответ,
поэтому недоступный коллектор показывает кешированную картину с явной пометкой, а не
пустой блок. Обновляется она каждые 30 секунд, пока страница настроек открыта, — столько
же ждёт и своя страница монитора, — и перестаёт спрашивать, как только страница закрыта.
Четыре состояния
| Состояние | Правило коллектора | Слово | Цвет |
|---|
ok | в окне ошибок не было | Без ошибок | #22c55e |
single | ошибки есть, установка одна | Локальный сбой | #f59e0b |
issues | ошибки есть, установок две и больше | Сбои | #ef4444 |
down | установок три и больше, последняя ошибка не старше пяти минут | Лежит | #ef4444 |
Правило и его пороги живут в коллекторе, а не в плагине: плагин берёт готовое status и
переводит его в слово и цвет. single янтарный, а issues и down красные намеренно —
одна сообщившая установка чаще всего означает её собственную сеть, ключ или регион, и
красный цвет на таком вечере был бы криком о чужой беде. Слова и цвета взяты со страницы
монитора, чтобы плагин и сайт не рассказывали разное об одной и той же модели; поверхностями
при этом остаются обычные токены темы --dsw-alias-*, потому что у страницы монитора одна
тёмная тема, а у этой две.
Коллектор старше v2 отвечает словами up | slow | down. Они переводятся как up → ok,
slow → issues, down → down, а single от него просто не приходит: пока сервер не
обновлён, локальный сбой неотличим от аварии провайдера. Незнакомое слово принимается за
ok — модель, о которой в окне ничего не сообщили, это модель, на которую никто не
пожаловался, а не пятая категория, которой на проводе не было.
Окна и полосы
Переключатель окна — 1 час, 24 часа, 7 дней, 30 дней, по умолчанию сутки — это состояние
самой панели, а не настройка: в config.json он не пишется, и один взгляд на прошлый
месяц не становится предпочтением для всех остальных поверхностей. Корзин в ответе
столько, сколько прислал коллектор, и ширина корзины обязана быть целым кратным окну
сбора успехов: процент невозможен для корзины уже окна, а распределить пятнадцатиминутный
успех по минутным корзинам можно, только выдумав распределение — ровно то, от чего
предыдущая версия отказалась в полосе истории.
| Период | Прежняя ширина | Теперь | Кратно окну в 15 минут? |
|---|
| 1 час | 60 × 1 мин | 4 × 15 мин | 1 мин не кратно 15, увеличено |
| 24 часа | 48 × 30 мин | 48 × 30 мин | 30 = 2 × 15, без изменений |
| 7 дней | 48 × 3,5 ч | 48 × 3,5 ч | 210 = 14 × 15, без изменений |
| 30 дней | 48 × 15 ч | 48 × 15 ч | 900 = 60 × 15, без изменений |
Меняется только часовой период, и причина — арифметика, а не вкус. Отказы при этом имеют
точные метки времени и могли бы жить в минутных корзинах, но две разные ширины в одной
полосе — это две шкалы на одном рисунке.
Подпись ширины («1 столбик = 15 мин») берётся из самого массива корзин, а не из
таблицы по периоду. Это не стилистика: окно сбора настраивается, значит настраиваемое окно
означает настраиваемую ширину корзины, а не второе правило о ней. Таблица по периодам была
бы вторым таким правилом, и два правила разошлись бы в первый же день, когда окно
изменили; рисунок при этом продолжал бы утверждать минутную ширину при пятнадцатиминутных
данных. Когда массив не даёт измерить ширину — меньше двух меток времени, — подписи нет
вовсе: полоса без подписи честна в своём незнании, а подпись из таблицы сообщила бы
разрешение, которого у данных нет. Рисуются корзины в масштабе max(1, …counts), то есть
одна-единственная ошибка уже видна.
Строка модели
Слово состояния, «сбоев N от M установок», «последняя X назад», «аптайм N%», «затронуто
N% установок», когда знаменатели есть, и полоса корзин с подписью ширины; ниже — строка
последней ошибки с её временем, кодом и собственным текстом провайдера. Процент и охват
стоят рядом под разными именами, потому что это две разные величины: одна про доступность
трафика, другая про то, насколько широко известны отказы.
Строка ведёт счёт сбоями, а не записями, и причина арифметическая: шапка публикует
summary.fails, поэтому строки с числом записей не сложились бы в её итог — заголовок «39»
над строками, дающими в сумме 13, и читателю неоткуда узнать, какое из двух чисел неверно.
Страница, противоречащая сама себе, — это тот же дефект §5.1, только в двух экземплярах на
одном экране. Правило состояния при этом по-прежнему считается по записям: чтение и
отображение — разные заботы, и ни порог из §6, ни совместимость из §5.1 не зависят от того,
какое число напечатано. Ответ коллектора старше v3 не несёт fails, и тогда строка
показывает записи под их собственной подписью — это единственная величина, которую такой
ответ содержит.
Часть отказов приходит без пригодного meta.id, и их не на кого посчитать. Тогда «ошибок
3 от 1 установки» молча обещает широту, которой на самом деле не измерили, — а это ровно
то число, по которому решают, доверять модели или нет. Поэтому рядом с ним появляется
оговорка: «(все сбои анонимны)», когда анонимных столько же, сколько сбоев, и «(анонимно
2 из 3 сбоев)», когда часть. Когда анонимных нет — а это обычный случай — не пишется
ничего.
Знаменатель у оговорки — fails, а не число записей, и это не мелочь. Начиная с v3
anonymous считается как сумма счётчика повторов n, то есть это число сбоев, а не
отчётов; нарисованное против числа записей, оно дало бы пару чисел, каждое из которых
по отдельности верно, а вместе они не значат ничего — худший род ошибки, потому что на
экране ничто не выглядит сломанным. Поэтому единица измерения названа в самой фразе:
«сбоев» для отказов и «отчётов» для записей, и ответ коллектора старше v3, где fails
нет, получает оговорку против записей с его собственной подписью.
Та же ловушка этажом выше, и она стоит отдельного абзаца. Поле installs.anonymous — тоже
сумма n, то есть сбои, — но лежит внутри объекта, два других поля которого считают
установки. Имя и контейнер согласованы, а семантика нет, поэтому контейнер сам
подсказывает неверное чтение: на живом замере это window: 5, reference: 5, anonymous: 4,
и фраза «4 из 5 установок анонимные» читалась бы безупречно, сравнивая сбои с установками.
Единственный правильный партнёр этого поля — summary.fails, поэтому шапка панели не рисует
анонимную долю вовсе, и это закреплено проверкой на отсутствие, а не только комментарием:
комментарий рядом с местом рендера — причина, а не механизм.
Ответ коллектора старше v2 не несёт ни installs, ни affectedPct, ни
minutesSinceLast. Строка показывает число ошибок без «от M установок», не показывает
долю вовсе и берёт «последняя X назад» из lastError; из шапки исчезает член про охват.
Панель становится беднее, но не неправильнее: ни «от 0 установок», ни «затронуто 0%» на
ней не появляется никогда.
Шапка блока: «моделей N · без ошибок N · локально N · сбои N · лежат N · ошибок за период
N · затронуто M из K установок». Процента в шапке нет ни на одном окне, и это не
упущение: на 30 днях окно и справочный период совпадают, числитель и знаменатель — одно и
то же множество установок, и «затронуто 100% установок» читалось бы как вердикт, означая
лишь, что долю измерили саму против себя. Коллектор отвечает на таком окне
summary.affectedPct: null, а шапка показывает сами счётчики — «затронуто 47 из 47
установок», — которые остаются честными числами и говорят то же самое без притворной
точности. Доля каждой отдельной модели при этом настоящая на любом окне, включая
тридцатидневное: её знаменатель — вся база установок, а числитель — установки одной
модели, поэтому «затронуто 4,3% установок» в строке модели остаётся осмысленным там, где
та же цифра в шапке была бы тавтологией.
Коллектор, который не знает часа
Значки читают окно 1h, а коллектор первой версии его не знает и отвечает на него
ошибкой. Плагин при этом обновляется раньше, чем разворачивается коллектор, поэтому такой
промежуток — обычное состояние, а не авария: хост-половина отдаёт 502 без единой
модели, значки исчезают все — и в списке моделей, и в кнопке выбранной модели, — значки
производителя остаются на месте, а панель на сутках продолжает работать как работала.
Пустой значок здесь честнее любого числа: окно, которое коллектор не может назвать, это
окно, в котором он не назвал ни одной модели. Если переключить панель на час, она скажет
об этом словами и назовёт окно — «Коллектор не ответил за период «1h»: …», — потому что
«коллектор не ответил» и «коллектор не знает этого окна» требуют разных действий.
Транспортная ошибка убирает значки точно так же: в отличие от панели, которая может
показать кешированную картину с пометкой о её моменте, значок не умеет сказать «это мы
знали некоторое время назад».
Пустой ответ — нормальный
Агрегация коллектора ещё пишется, и он отвечает total: 0, нулём ошибок и пустыми
корзинами на всех окнах. Панель рисует это как находку, а не как поломку: «ошибок нет» —
ответ, и он показан словами вместо пустых полос. Когда агрегация заработает, те же строки
начнут показывать настоящие аварии, и менять для этого страницу не придётся. Это свойство
коллектора, а не плагина.
Значки аптайма в списке моделей и в кнопке выбранной модели
У строк, которые коллектор назвал, в списке моделей появляется значок состояния — справа
в строке, там же, где harness рисует свою собственную плашку, и рядом с ней, а не поверх
неё. Процента на нём нет: ok — тихая зелёная точка без числа, single — янтарная плашка
с числом ошибок, issues и down — красная плашка с числом ошибок. Число ошибок и цвет
состояния читаются быстрее, чем 97,9%, которые ничего не значили.
Строка, которую коллектор не назвал, не получает ничего — и именно поэтому у ok точка, а
не пустое место: «про эту модель ничего не известно» и «у неё всё хорошо» обязаны
выглядеть по-разному. Подсказка несёт остальное — «3 ошибки от 2 установок за последний
час · последняя 4 мин назад», с той же оговоркой про анонимные отчёты, — а для ok
говорит «ошибок не было за последний час».
Тот же значок рисуется и в кнопке выбранной модели — между названием модели и индикатором
рассуждений, если сборка его рисует, а если не рисует, сразу за названием. Кнопка
единственное место, где модель меняется под значком, поэтому правило одно: при смене
выбранной модели значок либо переписывается, либо исчезает — состояние прошлой модели в
кнопке не остаётся. Состояния, цвета и окно у него ровно те же, что и у значков в списке,
а выключатель «Иконка в кнопке выбранной модели» убирает с кнопки и значок
производителя, и значок аптайма: это слово пользователя о том, писать ли в кнопку вообще.
Значок описывает последний час и только его: окно панели на него не влияет, иначе
подсказка врала бы о числе, которое показывает. Рисует значки тот же проход, что и значки
провайдера, поэтому общий выключатель значков убирает и их, а выключатель «Показывать
аптайм моделей» — только их.
Это правило отрисовки, а не новое значение состояния: значок и панель рисуют одно и то же
status коллектора, просто значок говорит цветом и числом, а панель — словом, числом и
полосой.
Как выключить
Выключателем «Показывать аптайм моделей» в Настройки → Rustvy (по умолчанию
включён), uptimeView: false в <профиль>\.dsh-rustvy\config.json либо переменной
окружения DSH_RUSTVY_NO_UPTIME=1, которая прячет панель независимо от настройки.
Выключенный вид
не читает ничего: ни панели, ни значков аптайма в списке моделей нет, и запрос к зеркалу
не уходит вовсе. Форма
ответа коллектора, его поля и контракт зеркала описаны в
docs/uptime-view.md.
Таймаут простоя стрима
Адаптер @deepseek-ai/dsh-llm-pi-ai вешает watchdog на каждое чтение стрима: если за
streamIdleTimeoutMs миллисекунд по стриму не пришло ни одного чанка, соединение рвётся с
ошибкой вида pi-ai stream idle timeout after 300000ms. Собственный дефолт адаптера —
300000 мс, пять минут. Модель с длинным reasoning (Claude Opus на high/max) молчит до первого
токена дольше, поэтому watchdog обрывает живой запрос на середине. Сам адаптер лежит в бандле
харнеса и правке не подлежит — менять нужно конфигурацию маршрута, которую он читает.
Плагин поднимает это значение втрое: с 300000 до 900000 мс, то есть до пятнадцати минут, и
этого хватает на первое слово даже очень медленной модели. Значение настраивается в разделе
«Таймаут простоя стрима» на странице плагина: пресеты 5 минут (дефолт адаптера), 15 минут (по
умолчанию), 30 минут, 1 час, 48 часов и «сбросить», плюс любое целое число миллисекунд от 1 до
2147483647.
Механика, которую стоит понимать правильно:
- Плагин пишет значение в профили маршрутов в
cordis.patch.yml — в rustvy и в клоны
кольца ключей (rustvy-k2, rustvy-k3 и далее). Именно их читает адаптер. В собственный
config.json плагина значение не пишется: там хранится намерение, а не результат, поэтому
строка «сейчас на маршрутах» показывает то, что действительно лежит в профиле, а не то, что
плагин собирается применить.
- Запись происходит только если значение отличается от уже лежащего в профиле. Число,
проставленное руками в
cordis.patch.yml, переживает каждую загрузку плагина: автоприменение
сначала сравнивает, а не перезаписывает при каждом старте.
- «сбросить» (
null) удаляет поле из профилей, и маршруты возвращаются к собственному
дефолту адаптера — 300000 мс. Это штатный способ отменить вмешательство плагина.
0 в настройках означает «не трогать маршруты вовсе». Это не значение таймаута, а отсутствие
намерения, и до профиля оно не доходит никогда.
- Ноль записать в профиль нельзя в принципе: схема адаптера отвергает неположительные значения,
и маршрут с
streamIdleTimeoutMs: 0 перестал бы отвечать вовсе. Поэтому плагин принимает 0
как настройку, но отказывается записать его как значение — и сообщает об этом прямо, а не
молча подменяет единицей. Дробные значения отклоняются по той же причине: setTimeout в
адаптере их округлил бы, и поле означало бы не то, что ввёл пользователь.
Перезапуск DSH не нужен: значение действует на следующие запросы.
Против блокировки ТСПУ
Раздел «Против блокировки ТСПУ» управляет фрагментацией ClientHello — разбиением первого пакета
TLS-рукопожатия на части, чтобы имя хоста (SNI) не читалось целиком в одном пакете. Это уровень
транспорта плагина; адаптер модели здесь ни при чём.
Три положения переключателя:
- выключена — транспорт обычный.
- auto — начинается обычным транспортом и переключается на фрагментацию только после двух
подряд сбоев транспорта. Это положение ничего не стоит, пока не понадобится: дорогие ~1,5 с
платятся внутри окна блокировки, а не заранее. Сбоем транспорта считается отказ соединения или
таймаут; HTTP-ответ 4xx или 5xx им не считается — транспорт, доставивший 502, работает.
- always — фрагментировать сразу и всегда.
Два числа рядом: размер фрагмента в байтах (1..64, по умолчанию 16) и пауза между фрагментами в
миллисекундах (0..100, по умолчанию 3).
16 байт — не регулятор скорости. Это размер, при котором имя хоста гарантированно разрывается
между пакетами. Подняв его до 64, можно оставить имя целым внутри одного пакета и тихо перестать
работать: настройка останется включённой, а эффект исчезнет. Больше здесь не значит лучше.
Замеренная цена на Windows — около 1,5 с на каждое новое соединение при 16 байт и паузе 3 мс:
ClientHello здесь занимает 1587 байт (TLS 1.3 с постквантовым ключевым материалом), то есть 100
фрагментов, а гранулярность setTimeout на Windows составляет около 15,6 мс. Keep-alive означает,
что платится это раз на соединение, а не раз на запрос.
Как фрагментация доходит до запросов модели
Запросы модели идут через глобальный fetch процесса, а не через запросы плагина, поэтому до них
фрагментация доходит только вторым уровнем — он ставит фабрику маршрутов плагина глобальным
диспетчером undici. Уровень экспериментален: он влияет на исходящий трафик всего процесса, а не
только плагина.
По умолчанию второй уровень включается сам (tlsFragmentGlobalAuto, по умолчанию true).
Плагин повышает фрагментацию до запросов модели, когда собственные его запросы замерили, что
обычное рукопожатие не проходит, а фрагментированное проходит, — и снимает повышение обратно, когда
канал снова отвечает обычным способом. Никакой ручной переключатель для этого трогать не нужно.
Повышение срабатывает только по замеру, поэтому в положении always оно не происходит. Это не
оговорка, а намеренное правило: щёлкнутый руками выключатель — не свидетельство того, что обычный
транспорт не работает, и ценой реакции на него стали бы ~1,66 с на каждом запросе модели, то есть
плата, которую пользователь уже заплатил один раз, взималась бы со всех остальных запросов.
Переключатель «Включить фрагментацию ClientHello и для запросов модели» — теперь принудительное
включение, способ не ждать замера. Он по-прежнему выключен по умолчанию и по-прежнему нужен, когда
фрагментировать хочется сразу.
Сигнал для повышения дают собственные запросы плагина. Отсюда два следствия, которые стоит
понимать правильно. Если прокси включён и работает, эти запросы проходят, и повышения не происходит —
это правильно: значит, ничего не заблокировано. И строка «транспорт сейчас» может показывать plain,
а трафик модели при этом не фрагментируется — это верное состояние, а не поломка, и заводить об
этом баг не нужно.
Фрагментация и прокси сочетаются
Раньше — нет: включённый прокси отключал фрагментацию для того же хоста. Теперь plaintext-прокси и
фрагментация работают вместе, и ClientHello фрагментируется внутри туннеля. Причина, по которой
это вообще возможно: туннель CONNECT не шифрует то, что по нему передают, поэтому DPI видит
ClientHello внутри туннеля ровно так же, как видел бы его без прокси, — и разорвать имя хоста можно
на том же месте.
Замеренная цена: 285 мс на один туннель и 1812 мс на туннель с фрагментацией. Хосты, не
входящие в область действия плагина, не затрагиваются вовсе — туннель для них остаётся обычным.
Ограничение, которое нельзя пропустить: сочетается только plaintext-прокси. Прокси с https:// и
SOCKS5 по-прежнему идут на собственных агентах undici, и фрагментации внутри них нет. Если у вас
прокси https://, не считайте, что сочетание у вас есть, — его нет.
«Диагностика канала»
Кнопка «Диагностика канала» в том же разделе измеряет канал по слоям — DNS, TCP, обычное и
фрагментированное TLS-рукопожатие, HTTP-запрос по каждому из них — и выдаёт вердикт. Слои важны тем,
что сбой на разных этажах означает разное: «не проверялось» (слой выше уже всё объяснил), «отказ»
(пробовали и не вышло) и «нет замера» (слой вообще не сообщён) — три разных состояния, и у них
разные способы починки.
Вердикты:
ok — канал проходит целиком;
dns, tcp — обрыв до рукопожатия;
tls — обычное рукопожатие не проходит;
tls-plain-blocked — это подпись блокировки ТСПУ: обычное рукопожатие отбрасывают, а
фрагментированное проходит. Самый информативный вердикт во время блокировки;
frag-unavailable — свидетельство против фрагментации: фрагментированное рукопожатие не
прошло. Включать фрагментацию при таком вердикте бессмысленно;
http, bad-origin — рукопожатие прошло, но запрос не удался, либо адрес провайдера в настройках
неверен и измерять нечего.
И то, что разрывает замкнутый круг «пока заблокировано — не измерить, а когда отпустило — измерять
нечего»: при сбое транспорта плагин сам записывает послойный замер в свой каталог состояния.
Запись троттлится до одной на 120 секунд и ограничена 40 последними записями. То есть свидетельство
из окна блокировки переживает это окно, и его можно показать потом — именно поэтому кнопка полезнее
всего во время блокировки, а не после неё.
Строка «транспорт сейчас» показывает живое состояние транспорта, а не настройку: plain или frag.
В положении auto до первого сбоя транспорта она честно говорит «включено, но сейчас plain» — это не
поломка, а нормальное состояние, в котором фрагментация ещё не понадобилась.
Маршруты
Одна регистрация префикса на собственном веб-сервере harness. Маршруты плагинов не
видны фильтру запросов хоста, поэтому каждый из них проверяет собеседника сам:
только локальный адрес, только локальный Host, только same-origin
Sec-Fetch-Site и только совпадающий Origin.
| Маршрут | Метод | Ответ |
|---|
/dsh-rustvy/catalog.json | GET | зеркальный каталог плюс настройки, с которыми браузерная половина рисует |
/dsh-rustvy/logo/<имя>.svg | GET, HEAD | один кешированный логотип, при первом запросе забираемый с источника; 404, когда такого значка у источника нет, 400 для любого имени, не совпадающего с [A-Za-z0-9._-]+.svg |
/dsh-rustvy/models.json | GET | сгенерированный список моделей и его расхождение с установленным; ?cached=1 отдаёт последний вычисленный план вместо повторного чтения эндпоинта |
/dsh-rustvy/models.yaml | GET, HEAD | тот же список в виде готовой к вставке строки cordis.patch.yml; ?cached=1 как выше |
/dsh-rustvy/state | GET | состояние зеркала: версия, число моделей, кешированные логотипы, маршрут, прокси (учётные данные маскируются), все пути к файлам — включая install, errorsQueue и uptime, — последняя ошибка, последнее вычисленное расхождение, сводка кольца ключей, блок routeBootstrap и блок errorReport. В routeBootstrap два шага: provider — исход создания маршрута при монтировании (state из created|exists|failed|skipped, число моделей или null, если их никто не считал, source списка, keySet и причина отказа устойчивым кодом) — и defaultModel, который null целиком, если шаг не выполнялся. keySet пересчитывается на каждый запрос, а не замораживается на монтировании, поэтому ключ, добавленный кольцом, виден без перезапуска. В errorReport: включён ли сбор, адрес коллектора, анонимный идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя ошибка отправки и разбивка сбоев по видам. Собственных запросов не делает; сводка кольца синхронная и без чтения учётных данных, поэтому масок в ней нет — только счётчики и маршруты. Блок errorReport страница настроек не рисует вовсе — он для тех, кто читает маршрут руками; routeBootstrap она рисует условной строкой. У хост-половины старше сбора ошибок нет errorReport и двух новых путей, у хост-половины старше создания маршрута нет — ключ отсутствует целиком, и страница рисует в этом случае ничего |
Без перезапусков
Хост-половина перезагружает себя сама, тем же способом, что и dsh-proxy-gui:
строка загрузчика называет этот пакет, чья точка входа импортирует реализацию через
запрос со сбрасывающим кеш параметром ?rev=, а опрос каталога lib раз в секунду
останавливает предыдущую редакцию и запускает следующую, как только изменившийся
отпечаток повторится. Поэтому lib/host.js, lib/models.js, lib/keys.js,
lib/errors.js, lib/uptime.js и lib/net.js перезагружаются на лету, а перезапуск
нужен только при правке самой точки входа.
Браузерная половина приходит в открытую страницу через собственную живую композицию
плагинов harness; обновление страницы тоже работает.
Название, описание и значок в меню плагинов читаются из пакета на композиции,
поэтому изменение locale/*.json или assets/icon.svg требует перезапуска.
Требования и деградация
Проверено на dsh 0.2.1-rc.2. Пакет не объявляет ни engines.dsh, ни
peer-зависимостей, потому что не импортирует ни одного модуля @deepseek-ai/* — у
него ноль рантайм-зависимостей, а непроверенное требование к версии хоста дало бы в
маркете лишь ложные предупреждения о несовместимости.
Каждая возможность деградирует самостоятельно:
| Чего нет | Что происходит |
|---|
configEditor | расхождение по-прежнему вычисляется и показывается; кнопки записи и отката объясняют, что хост не отдаёт редактор |
маршрут dsh-llm-pi-ai | значки работают; половина со списком сообщает, что маршрут не найден |
| источник | кеш на диске отдаёт каждый значок и каждую модель, которые в нём есть |
| эндпоинт при живом каталоге | состав определяет остров, и страница об этом говорит |
credentials | значение ключа некуда записать: кольцо остаётся с одним основным ключом, добавление недоступно, и раздел «API-ключи» называет причину вместо того, чтобы показать форму, которая откажет |
configEditor — для кольца | значение существующего ключа заменить можно, маршрут для нового добавить нельзя: форма добавления выключена, и раздел объясняет почему |
маршрут ключа, потерянный после ручной правки cordis.patch.yml или отката списка моделей | ключ остаётся в кольце, его маршрут числится в routes.missing, запросы этого чата идут маршрутом профиля с видимой причиной, а кнопка «Починить маршруты» ({ action: 'repair' }) записывает маршруты кольца заново |
| коллектор аптайма | отчёты остаются очередью на диске и уходят, когда сайт отвечает, поэтому авария попадает в монитор с настоящими временами; на запросы к провайдеру это не влияет никак, а неотправленное и последнюю ошибку отправки показывают петлевые маршруты /dsh-rustvy/state и /dsh-rustvy/errors — на странице настроек этого нет |
| хост-половина старше сбора ошибок | /state приходит без блока errorReport и без двух новых путей: страница настроек рисует выключатель одинаково в обоих случаях, потому что сводку сбора она не рисует вовсе, а пути к файлам показываются только тогда, когда хост их назвал |
| вид монитора у коллектора | зеркало отдаёт последний удачный ответ с stale: true, и панель показывает кешированную картину с пометкой, от какого она момента; без кеша вовсе — 502, и панель называет причину вместо того, чтобы крутиться. Значки аптайма в списке моделей исчезают вместе с данными, а значки провайдера остаются: на запросы к провайдеру это не влияет никак |
коллектор старше v2, не знающий окна 1h | значки исчезают все — и в списке моделей, и в кнопке выбранной модели, — значки провайдера остаются, а панель на сутках работает как прежде и называет окно, которое не смогла прочитать, если переключить её на час. Это состояние между обновлением плагина и развёртыванием коллектора, и пустой значок в нём честнее числа |
Структура
| Файл | Роль |
|---|
lib/index.js | стабильная точка входа загрузчика; владеет циклом перезагрузки |
lib/host.js | маршруты, зеркало каталога, кеш логотипов, синхронизация моделей, кольцо ключей и подмена маршрута на agent/request, настройки |
lib/models.js | чистое отображение: что публикует источник → одна запись профиля маршрута, расхождение двух списков и YAML для ручной правки |
lib/keys.js | чистая арифметика кольца: названия, имена учётных данных и маршрутов, документы кольца и выбора чатов, сверка карты маршрутов с кольцом |
lib/errors.js | классификация сбоя на инфраструктурный и собственный, очистка текста от секретов и путей, анонимный идентификатор установки, сворачивающая очередь отчётов и её файл на диске |
lib/uptime.js | чистая форма вида монитора: допустимые окна, приведение ответа коллектора к полям, которые рисует панель, обрезка строк и clamping процентов. Не читает сеть и не держит кеш — этим владеет lib/host.js |
lib/net.js | один исходящий GET по маршруту процесса, затем по прокси из окружения |
client/client.js | браузерный набор, написанный вручную: декоратор, страница настроек, переключатель ключа в композере и скрытие групп-клонов в меню моделей |
locale/en.json, ru.json, zh.json | название и описание для меню плагинов |
assets/icon.svg | значок для меню плагинов |
cordis.patch.yml | строка загрузчика, которую подтягивает список bundles профиля |
install.ps1 | установка в профиль |
scripts/prepublish-check.mjs | гейт публикации: всё, что может установиться и при этом быть молча неправильным |
tests/ | проверки отображения, эмиттера YAML, кольца ключей, хост- и браузерной половин |
lib/models.js, lib/keys.js и lib/uptime.js намеренно чистые — без сети, файловой
системы и Cordis, — поэтому отображение можно проверить на реальном ответе источника,
арифметику кольца на выдуманном профиле, а форму вида монитора на ответе коллектора,
ничего не монтируя. Сети не касается и
lib/errors.js: отправитель внедряется извне, именно потому, что рассуждать об
очереди, чей транспорт импортирован, невозможно в тот момент, когда лежит сайт, о
котором она сообщает. Свои два файла этот модуль читает и пишет сам — идентификатор
установки, который можно было бы создать только внутри смонтированного хоста, не на
чем было бы строить. lib/host.js владеет всем, у чего есть время жизни, — включая
чтение вида монитора и кеш его последнего удачного ответа, — и именно
его заменяет перезагрузка.
Тесты
node tests\models-check.mjs # чистое отображение: живой источник -> записи, расхождение, аудит схемы
node tests\yaml-roundtrip-test.mjs # эмиттер против двух настоящих YAML-парсеров
node tests\model-sync-host-test.mjs # хост: подставные webServer и configEditor, настоящий запрос к источнику
node tests\api-keys-host-test.mjs # кольцо ключей: подставные credentials, configEditor и шина событий, без сети
node tests\error-report-host-test.mjs # сбор ошибок: классификация, сворачивание и доставка локальному коллектору
node tests\uptime-host-test.mjs # вид монитора: зеркало, кеш последнего ответа и пометка stale
node tests\model-sync-client-test.mjs # браузер: мини-React и подставной DOM, фиктивный /state
node scripts\prepublish-check.mjs # гейт публикации
models-check.mjs читает эндпоинт и остров каталога источника, генерирует список и
проверяет каждую запись против схемы адаптера: только объявленные поля,
положительные целые размеры, известные модальности и словарь рассуждения, который
предлагает уровень помимо off, но даёт боевое значение каждому уровню, кроме
off. Он же проверяет, что запасной путь по острову выбирает ровно те модели,
которые отдаёт эндпоинт, — именно это делает запасной путь безопасным для записи.
yaml-roundtrip-test.mjs разбирает настоящую строку cordis.patch.yml профиля (или
синтетическую той же формы, если профиля на машине нет), заново выдаёт её эмиттером
этого пакета и проверяет, что два независимых парсера — js-yaml и yaml — читают
её обратно одинаково, что все поля подключения сохраняются, что подстановка строки
оставляет соседей байт в байт и что расстановка кавычек переживает обратный ход:
зарезервированные слова, числа, ведущие индикаторы, концевой пробел и кавычки
внутри строки.
Хост-тест прогоняет зарегистрированные маршруты на подставных парах запрос/ответ и
подставном configEditor, наполненном маршрутом в том виде, в каком он есть у
профиля, и проверяет: план читает эндпоинт; модель, которой зеркало не видело, всё
равно получает настоящее отображаемое имя, а не идентификатор; написанный вручную
compat переживает перегенерацию и не изобретается для другой модели;
/models.yaml выдаёт строку патча, сохраняющую поля подключения и отбрасывающую
заменяемый список; запись сохраняет displayName, apiKeyEnv, api и baseURL и
заменяет только models; копия ложится на диск; повторное применение ничего не
делает и конфигурацию не трогает; /state перестаёт сообщать расхождение, которое
запись только что устранила; откат возвращает снятые модели и их compat;
нелокальный собеседник получает 403, а неверный метод — 405.
api-keys-host-test.mjs — 84 проверки кольца ключей на той же host-половине, с
подставным хранилищем учётных данных, подставным configEditor и подставной шиной
событий, без сети вовсе: эндпоинт маршрута смотрит на закрытый порт, поэтому и
чтение списка моделей, и проверка ключа детерминированно заканчиваются отказом, а
состав списка определяет зеркальный остров. Проверяется подмена: выбранный ключ
переименовывает маршрут, а модель, уровень рассуждения и лимит вывода переживают её
без изменений; чат без собственного выбора идёт маршрутом профиля; deepseek-account
и соседний маршрут с чужим ключом не переименовываются никогда; запрос без сессии
остаётся как пришёл; модель, которой клон не отдаёт, оставляет запрос на базовом
маршруте и попадает в fallback; чат без выбора следует за ключом по умолчанию, а
keyId: null возвращает его обратно. Проверяются отказы: пустой ключ, ключ с
пробелом, неизвестное действие (400), удаление основного ключа (409).
Проверяется удаление ключа: маршрут клона исчезает, сосед и базовый маршрут
выживают, значение стирается из хранилища, выбор чатов с этого ключа сбрасывается, а
сам чат продолжает ходить базовым маршрутом. Проверяются записи и счётчики: одно
добавление — одна правка конфигурации, замена значения не правит её вовсе, запись
списка моделей накрывает клон и сохраняет его собственное имя учётных данных, блок
для ручной правки содержит клон, repair возвращает потерянный маршрут, в
keys.json нет секрета, выгрузка снимает и маршрут, и слушателя, а с выключенной
возможностью не переименовывается ничего.
error-report-host-test.mjs — хост-тест сбора ошибок: классификация сбоев на
инфраструктурные и собственные, сворачивание отчётов по окнам и их доставка коллектору,
поднятому на локальном адресе, — то есть та же host-половина, на которой
работают два предыдущих хост-теста, поэтому маршрут /dsh-rustvy/errors, блок
errorReport в /state и очередь на диске проверяются вместе с тем, что именно
попадает в отправленный отчёт.
uptime-host-test.mjs — хост-тест вида монитора: зеркало на
/dsh-rustvy/uptime.json, три окна, кеш последнего удачного ответа на диске и пометка
stale с fetchedAt и причиной, когда коллектор недоступен. Проверяется и то, что
зеркало ничего не пересчитывает: ответ коллектора отдаётся как пришёл, а выключенная
настройка отвечает { ok: false } и не делает запроса вовсе.
Браузерный тест рисует страницу настроек мини-реактом, чьи ячейки хуков привязаны к
экземпляру компонента, и проверяет: раздел называется Rustvy; три внешних адреса —
настоящие ссылки, открывающиеся вне приложения; каждый путь к файлу показан с
кнопкой копирования; маршрут, строка профиля, сайт и размер значка редактируются и
сохраняются по потере фокуса; оба выпадающих списка сохраняются по изменению;
панель ручной правки читает и показывает блок; расхождение называет новые, снятые и
изменённые модели; кнопка записи включена при наличии расхождения, выключена при
актуальном списке и выключена, когда хост не отдаёт редактор; откат остаётся
выключенным, пока копии нет; каждое изменение уходит на нужный маршрут и сообщает,
что именно сдвинулось.
Тот же тест рисует и кольцо ключей: раздел «API-ключи» со списком, масками,
переименованием по потере фокуса, инлайн-формой «+», проверкой, назначением
ключом по умолчанию и удалением в два шага; объяснения на каждый отказ — нет
credentials, host-половина устарела, возможность выключена — вместе с
возвращением способности, когда причина устранена; переключатель «Ключи на
несколько чатов», который читает ответ хоста и пишет своё поле в /config; блок
«Маршруты ключей» со счётчиками подмены, числом чатов со своим выбором, путями к
обоим файлам, отсутствующим и отставшим маршрутами и кнопкой «Починить
маршруты»; предел в 12 ключей; переключатель композера, который не рисуется без
sessionId и при одном ключе, читает выбор своего чата, публикует его в
/session-key и предлагает добавить ключ; меню выбора моделей и окно /model,
которые не получают ни раздела ключей, ни его строк; стоимость обращений к хосту —
два взаимодействия подряд стоят одного чтения, открытие переключателя читает
всегда, а выбор обходится одним POST без дочитывания; и выгрузка, после которой в
меню не остаётся ничего своего. Отдельно проверяется, что значение ключа не
попадает ни в один URL и не остаётся на странице после добавления.
Он же рисует блок сбора ошибок: выключатель с его точной подписью — включённый по
умолчанию, в том числе когда фиктивный /state поля collectErrors не знает вовсе.
Отдельно проверяется отсутствие, и не только сводки: фикстура несёт полный блок
errorReport, и при этом на странице нет ни заголовка «Состояние сборщика», ни одной из
восьми строк сводки, ни их значений — ни анонимного идентификатора, ни разбивки по видам,
ни момента последней отправки, — ни одной из двух кнопок отправки, и в записанных
запросах нет ни одного обращения к /dsh-rustvy/errors. Ровно так же проверяется
снятый со страницы текст: девятнадцать строк, которые она раньше несла — вступление под
заголовком, обещание под выключателем сбора и семь абзацев в конце, — ищутся в дереве и
не находятся, а сами возможности при этом на месте. Удаление, которое никто не проверяет,
возвращается само. Единственная строка, пережившая снятие текста, проверена положительно:
предупреждение «Каталог не получен» рисуется, когда фиктивный /state несёт error, и
не рисуется, когда не несёт, — это не проза, а единственное место, где страница сообщает
о неудачном чтении каталога. Проверяется и хост-половина старше сбора ошибок: без блока
errorReport страница рисует ровно то же, что и с ним, и не бросает исключение.
Он же рисует блок аптайма — на четырёх фикстурах: всезелёный ответ, который коллектор
даёт чаще всего; наполненный, несущий все четыре состояния и оба крайних случая
(affectedPct: null и окно, где все отчёты анонимные); часовой ответ, из которого
значки берут свои числа, — а на любое окно длиннее зеркало в этом режиме отвечает
удвоенными счетчиками, поэтому значок, перерисованный не из своего окна, виден сразу; и
ответ коллектора старше v2, с тремя старыми состояниями, процентом и без единого поля
широты. Проверяются шапка, считающая состояния вместо процента, и строки моделей со
словами «Лежит», «Сбои», «Локальный сбой» и «Без ошибок»; окно: переключатель предлагает
все четыре окна, по умолчанию стоит 24h, спрашивает зеркало про ?range=1h и не пишет
ничего в /config, потому что это состояние панели, а не настройка; ширина корзины
подписана — 48 столбиков по 30 минут на сутках и 60 по минуте на часе; «ошибок 5 от 3
установок» и «затронуто 6,4% установок» — числа коллектора, а не пересчёт; доля без
знаменателя рисуется как «—», а не как 0%; последняя ошибка трёхчасовой давности читается
как «3 ч назад», а не как «только что»; оговорка про анонимные отчёты стоит рядом с
числом, которое она ограничивает; кешированный ответ показывает fetchedAt и
предупреждение с причиной; недоступный коллектор называет причину и оставляет кнопку
«Обновить» вместо вечного ожидания. Старый ответ рисуется беднее, но не неправильнее:
старые слова переведены, число ошибок показано без «от M установок», доля не показана
вовсе, и ни «от 0 установок», ни «затронуто 0%» на панели не появляется. Пятая фикстура —
тридцатидневное окно, на котором доля в шапке теряет смысл: шапка показывает счётчики
(«затронуто 47 из 47 установок») и не показывает процента, а доля отдельной модели на том
же окне остаётся настоящей («затронуто 6,4% установок»), и полоса говорит, что её
столбик — пятнадцать часов.
Отдельно проверяется промежуток между обновлением плагина и развёртыванием коллектора:
фикстура, в которой range=1h отвечает 502 без единой модели, а сутки работают как
прежде. Проверяется то, что иначе осталось бы догадкой, — путь значков не бросает
исключение и не зависает на незавершённом чтении: значки исчезают все, включая значок в
кнопке выбранной модели, значки предыдущего окна на экране не задерживаются, значки
провайдера остаются, панель называет окно, которое не смогла прочитать, а на сутках
продолжает рисовать строки. Транспортная ошибка — чтение, которое не ответило вовсе, —
проверяется тем же образом: значки уходят, а не застывают, потому что значок, в отличие от
панели, не умеет сказать, от какого он момента.
Отдельно проверяются значки аптайма в списке моделей — на часовой фикстуре, по одной
модели на каждое состояние: ok — тихая точка без числа, single — янтарная плашка с
числом ошибок, issues и down — красные плашки со своими числами, и все четыре цвета —
цвета монитора; точка при этом отличается от пустого места, поэтому «коллектор ничего не
сказал» и «ошибок не было» выглядят по-разному; подсказка называет окно («за последний
час»), число ошибок, число установок и давность; значок стоит в строке до её собственного
хвостового элемента и не теряет значок провайдера; второй проход не рисует второй значок;
строка, на которой уже есть плашка harness, сохраняет обе — наша оказывается соседом, а
не вложенным элементом; переключение панели на 30 дней не перерисовывает значки, у
которых в подсказке час; выключенный вид убирает значки и оставляет значки провайдера, а
выгрузка забирает их с собой. Отдельно проверяется значок в кнопке выбранной модели: он
несёт состояние выбранной модели и её цвет, стоит между названием и индикатором
рассуждений, а на кнопке без индикатора — сразу за названием; собственная обвязка кнопки
(индикатор, «Default», шеврон, кнопка остановки) и значок провайдера остаются
нетронутыми; при смене выбранной модели значок переписывается — точка тихой модели
становится красной плашкой сломанной, — а на модели, которую коллектор не назвал,
исчезает, и состояние прошлой модели в кнопке не задерживается; выключенная «Иконка в
кнопке выбранной модели» убирает с кнопки и значок аптайма, оставляя значки в списке
моделей. Выключатель аптайма проверяется так же, как остальные: постит своё поле в
/config, а в выключенном виде панель объясняет себя словами и не делает ни одного
запроса к зеркалу.
Он же рисует строку загрузки маршрута — по всем клеткам матрицы «состояние × задан ли
ключ», включая те, где правильный ответ является молчанием. Созданный маршрут показан с
числом моделей; отсутствующий ключ source переживается без догадки; переключённая
модель по умолчанию называет все три пришедшие строки; keySet: false утверждает и
направляет, true про ключ не говорит ничего, а null только направляет — и
отрисованный текст в последнем случае не содержит «не задан», иначе две фразы слились бы
при первой же переформулировке и вернули бы «ключ ещё не задан» на машину, которая
работает. Маршрут, который уже был, не рисует ничего, кроме строки про ключ, когда ключа
нет, и эта клетка проверена с двух сторон: на молчание при заданном ключе и на отсутствие
слова «создан» там, где плагин маршрут не создавал. Несозданный маршрут называет причину
словами хоста, действием рядом с ней идёт перезагрузка окна, а не кнопка обновления
каталога — при мёртвом источнике зеркало собирается с него же, — и про ключ в этом
состоянии строка не говорит вовсе: харнесс разрешал бы apiKeyEnv для маршрута, которого
нет. Незнакомый код причины и незнакомое состояние печатаются как пришли, а не заменяются
догадкой. Кнопки внутри строки нет ни в одной клетке, и это утверждается, а не
наблюдается. Отдельно проверена свежесть keySet: добавление ключа кольцом заставляет
страницу перечитать /state, а выбор ключа по умолчанию — нет, потому что наличие ключа
он не меняет. Утверждение сделано про запрос, а не про текст: набор перерисовывает
эффекты на каждом проходе, поэтому текстовая проверка прошла бы и без правки и ничего бы
не доказала. Отсутствие routeBootstrap целиком — не крайний случай, а популяция: так
отвечает любая установка с сегодняшней половиной хоста, и она не рисует ничего и не
бросает.
Он же рисует процент аптайма — и проверяет, что три его исхода различимы на экране:
замеренное число, прочерк и настоящий ноль дают три разные строки, а не одно значение и два
отсутствия. Отдельно закреплено, что процент старого коллектора не рисуется: строку
разрешает знаменатель, а не имя поля, поэтому uptime без fails рядом остаётся за
бортом — иначе снятое когда-то выдуманное число вернулось бы на экран под подписью
настоящего. Прочерк не объясняется причиной: выключенные успехи этой установки не
превращаются в объяснение нулевого знаменателя у коллектора. Часовой период проверяется на
4 корзины и подпись «1 столбик = 15 мин», причём подпись измеряется по массиву — это то
утверждение, которое было верным при прошлой ширине и покраснеет в момент, когда половины
встретятся, а не на интеграции. Заголовок панели проверяется на «сбоев за период», а не на
прежнее число записей, и на отсутствие обоих чисел под одной подписью.
Блок сбора данных проверяется целиком: подпись, оба выключателя и их порядок (успехи над
ошибками), положение ниже панели аптайма, а не среди настроек отображения, пояснение о цене
с его замеренными числами и условиями замера, окно из config.successWindowMs, а не из
константы, собственный итог установки — и его отсутствие, когда счётчиков в состоянии нет,
потому что число, которого состояние не содержит, страница не рисует. Замеренный ноль
рисуется нулём, а не прочерком: это ровно то место, где два нуля выглядят одинаково и только
один из них факт. Кнопок сброса или повтора в блоке нет, и это утверждается явно — так же,
как для строки загрузки маршрута, чтобы кнопку нельзя было добавить, не покраснив проверку.
Сочетание «успехи выключены, но счётчик считает» подаётся фикстурой напрямую, с комментарием
о том, что реальная машина его не выдавала, пока вентиль отправки не отделили от учёта;
теперь, когда разделили, комментарий заменён на факт достижимости.
Половина, рисующая значки, автоматическим набором в этом репозитории не
покрыта — и это стоит знать заранее. Она работает с DOM уже открытой страницы,
поэтому проверяется глазами: какие строки получают значок и какой именно, что
строки уровней усилия, заголовки производителей и поле поиска остаются пустыми, что
вместо неполученного логотипа появляется плашка с монограммой, что добавленная
позже строка тоже получает значок, что повторный проход ничего не удваивает и что
выгрузка убирает добавленное. model-sync-client-test.mjs покрывает страницу
настроек и переключатель ключа в композере, но не рисование значков; скрытие
групп-клонов в меню — та же работа с DOM уже открытой страницы, и набор проверяет
там только то, что меню не получает ни раздела ключей, ни его строк.
Лицензия
MIT — см. LICENSE.