SIRINVIDEO CCTV Документация администратора и пользователя На сайт 2026.08

Ноды и хранилища

Создание ноды и получение node.conf, управление конфигурацией, хранилища архива, лицензия, журналы.

Раздел описывает управление флотом через API: ноды (серверы, которые принимают видео с камер, раздают его зрителям и пишут архив), их конфигурацию, хранилища DVR, лицензию и журналы.

Ноды — объекты уровня установки, а не организации, поэтому почти все методы этого раздела доступны только суперадминистратору. Исключений три: GET /api/v1/nodes/placement-options, POST /api/v1/nodes/recommend и GET /api/v1/sessions — их вызывает и администратор организации, и результат всегда ограничен её областью видимости.

Во всех примерах используются переменные:

BASE=https://cctv.example.com
TOKEN=$(curl -sS -X POST "$BASE/api/v1/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"login":"admin","password":"ВАШ-ПАРОЛЬ"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')

NODE_ID=<UUID>          # идентификатор ноды, возвращается при создании

Общие соглашения — заголовки, коды ошибок, пагинация, потоки SSE — описаны в Введении и аутентификации. Пошаговая установка ноды с теми же вызовами — Установка с нуля: Node.

Что нужно знать до первого запроса#

  • Заголовок Authorization: Bearer $TOKEN обязателен для всех методов раздела.
  • Тело запроса — только JSON, и заголовок Content-Type: application/json обязателен. Без него запрос отклоняется до обработчика: 415 с текстовым (не JSON) телом Expected request with Content-Type: application/json.
  • Пустое значение числового параметра запроса — ошибка, а не «параметр не задан»: ?limit= даёт 400 Failed to deserialize query string. Ненужный параметр не отправляйте вовсе.
  • Ошибки обработчиков всегда имеют вид {"error":"<строка>"}.
  • Несуществующий путь возвращает 501 {"error":"not_implemented"}, а не 404. Существующий путь с неподдерживаемым методом возвращает 405. Список таких путей — в конце страницы.

Методы, которые проксируются на ноду#

Часть методов (certs, license, config, logging, logs, sessions) Plane выполняет не сам: он находит адрес управления нодой, выпускает короткоживущий токен на 50 секунд, переадресует вызов и возвращает ответ ноды как есть. У всех таких методов общий набор ошибок:

Код Тело Когда
502 {"error":"node_unreachable"} нода не ответила, либо её ответ превысил потолок 8 МиБ
502 {"error":"node_bad_reply"} ответ ноды не является JSON
404 {"error":"no active node"} у записи ноды нет пригодного адреса управления
409 {"error":"node_bootstrap_not_generated"} ключ управления нодой отсутствует или непригоден

Ноды#

GET /api/v1/nodes#

Суперадмин — метод уровня установки, без разделения по организациям.

Список всех созданных нод с их идентичностью, состоянием и краткой сводкой. Телеметрия сюда намеренно не входит (она идёт отдельным потоком мониторинга), поэтому список дёшев и его можно запрашивать на каждой загрузке страницы.

curl -sS "$BASE/api/v1/nodes" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Ответ — массив (не страница: пагинации и параметров запроса здесь нет), отсортированный по name:

[
  {
    "id": "<UUID>",
    "name": "node-1",
    "public_url": "https://node.example.com",
    "control_url": "https://node.example.com",
    "tls_identity_source": "node_managed",
    "state": "active",
    "created_at": "2026-01-01T00:00:00Z",
    "bootstrap_generation": 1,
    "last_seen_at": "2026-01-01T12:00:00Z",
    "version": "2026.8.0",
    "disabled": false,
    "storage_names": ["dvr1"],
    "system_telemetry": null,
    "storage_telemetry": null,
    "telemetry_at": "2026-01-01T12:00:00Z",
    "cores": 0
  }
]
Поле Тип Смысл
id uuid идентификатор ноды, он же id в node.conf
name string имя для человека
public_url string адрес, с которого браузеры забирают видео
control_url string | null адрес, по которому Plane управляет нодой; null — используется public_url
tls_identity_source string node_managed (свой сертификат) или plane_shared_local (копия сертификата Plane)
state string pending | active | retired
bootstrap_generation integer поколение конфигурации; растёт при каждом изменении, влияющем на bootstrap
last_seen_at string | null когда нода последний раз выходила на связь
version string | null версия сборки ноды
disabled bool нода выведена из работы
storage_names string[] | null только инициализированные дисковые хранилища
system_telemetry, storage_telemetry null на этом методе всегда null
cores integer число рабочих ядер из node.conf; 0 = все производительные ядра

Примечания.

  • cores: 0 — это признак «все производительные ядра», а не «ни одного ядра». Не приводите его к единице в своих скриптах.
  • Отдельного метода «прочитать одну ноду» нет: GET /api/v1/nodes/<UUID> вернёт 405. Возьмите нужную запись из этого списка.

POST /api/v1/nodes#

Суперадмин

Создаёт запись ноды до того, как её процесс существует, и возвращает готовый текст node.conf. Plane сам генерирует UUID ноды и пару ключей управления (закрытая половина остаётся в базе), ставит state: "pending" и bootstrap_generation: 1. Отдельного протокола спаривания нет — вы просто переносите выданный файл на сервер ноды.

Тело запроса

Поле Тип Обязательно Правила
name string да обрезается по краям; непустое, не длиннее 128 символов, без управляющих символов
public_url string да адрес раздачи видео
control_url string | null нет адрес управления; пустая строка равнозначна отсутствию

Оба адреса приводятся к каноническому виду: абсолютный http/https-origin с именем хоста, без логина-пароля в URL, без строки запроса, без якоря, путь строго /; завершающая косая черта отбрасывается.

Пример: создать ноду и сразу сохранить конфигурацию в файл

curl -sS -X POST "$BASE/api/v1/nodes" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"node-1","public_url":"https://node.example.com"}' \
  | python3 -c 'import sys,json;d=json.load(sys.stdin);open("node.conf","w").write(d["config"]);print("id:",d["id"],"generation:",d["generation"])'
# id: <UUID> generation: 1

Ответ — 201 с полем config, в котором лежит весь файл целиком:

{ "id": "<UUID>", "name": "node-1", "generation": 1,
  "config": "# SirinVideo CCTV node bootstrap - generated by Fleet.\n# Edit only system-local listeners/TLS/tuning. Streams and storages arrive from plane.\nhttp 80;\nhttps 443;\nrtsp 8554;\ncores 0;\nlog_level info;\n\ntls {\n  self_signed_names node.example.com;\n}\n\ncctv {\n  id …;\n  plane_url \"https://cctv.example.com\";\n  generation 1;\n  plane_pubkey \"…\";\n}\n" }

В сгенерированном файле: слушатели, rtsp 8554;, cores <n>;, log_level info;, блок tls { } с самоподписанной парой на имя ноды — и блок cctv { … } с идентификатором ноды, адресом Plane, номером поколения и открытым ключом Plane для проверки команд.

Слушатели выводятся из схемы эффективного адреса управления (control_url, а если он не задан — public_url). Это единственное решение, из которого следует всё остальное:

Эффективный адрес управления Слушатели в config Блок tls { }
https://node.example.com http 80; https 443; self_signed_names node.example.com
https://cctv.example.com:9443 (любой порт, кроме 443) http 9080; https 9443; self_signed_names cctv.example.com
http://node.example.com:8090 http 8090; нет

Порт 9080 в средней строке — не опечатка: нестандартный порт HTTPS означает, что рядом стоит Plane и владеет портами 80 и 443, поэтому обычный слушатель ноды переносится на 9080. Для http:// без явного порта используется 8090.

Ошибки

Код Тело Причина
400 {"error":"bad_node_name"} имя пустое, длиннее 128 символов или содержит управляющие символы
400 {"error":"bad_node_url"} адрес не проходит канонизацию
409 {"error":"node name already in use"} имя занято
503 {"error":"db_unavailable"} недоступна база
Задавайте https:// сразу

Ограничения «при создании только http://» больше нет: нода получает самоподписанный сертификат на своё настоящее имя вместе со стартовой конфигурацией и поднимается по HTTPS с первой секунды. Plane принимает этот сертификат осознанно — нода удостоверяется выданным ей ключом управления, а не сертификатом.

Единственное исключение — нода за реверс-прокси, который сам терминирует TLS: тогда public_url задаётся по https:// (внешний адрес прокси), а control_urlявно по http://, потому что сама нода TLS не поднимает.

Файл выдаётся один раз

Это единственный ответ, в котором Plane отдаёт свежесгенерированную конфигурацию для данного поколения. Сохраните её. Повторно получить тот же файл (без смены ключа) можно методом GET /api/v1/nodes/{id}/bootstrap.

PATCH /api/v1/nodes/{id}#

Суперадмин

Меняет адреса уже существующей ноды и уведомляет службу согласования конфигураций.

Параметры пути: id — UUID ноды.

Тело запроса

Поле Тип Обязательно Правила
public_url string да те же правила канонизации, что при создании
control_url string | null нет пустая строка или пробелы — сбросить в null
curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"public_url":"https://node.example.com","control_url":"https://node.example.com"}'
{ "id": "<UUID>", "public_url": "https://node.example.com", "control_url": "https://node.example.com" }

Ошибки: 400 {"error":"bad_node_url"}; 404 {"error":"no such node"}; 503 db_unavailable.

Ноду со своим TLS нельзя опрашивать по http://

Нода, которая терминирует TLS, перенаправляет весь обычный HTTP на HTTPS. При таком перенаправлении теряется заголовок авторизации: Plane получает 401, управление молча перестаёт работать, а нода при этом выглядит живой — она отвечает и раздаёт видео, но не принимает конфигурацию и уходит в offline.

Поэтому оба адреса такой ноды должны быть по https://. Обычно это уже так с момента создания; этим методом чинят ноду, заведённую по http://, и переносят ноду на другое имя или порт:

curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"public_url":"https://node.example.com","control_url":"https://node.example.com"}'

Дополнительный эффект: конфигурация с логинами и паролями камер уходит на ноду в шифрованном виде, а не открытым текстом.

Смена схемы меняет и слушатели

Слушатели в node.conf выводятся из схемы адреса управления, поэтому после PATCH заберите свежую конфигурацию (GET /api/v1/nodes/{id}/bootstrap) — либо поправьте слушатели руками в верхней части существующего файла, если в неё уже внесены свои правки: она принадлежит вам и заменой затирается. Уже работающая нода продолжает вещать на прежних портах — новые применяются при перезапуске.

PATCH /api/v1/nodes/{id}/cores#

Суперадмин

Задаёт число рабочих ядер ноды. Поднимает bootstrap_generation, поэтому нода перечитает свою конфигурацию; фактически новое значение вступает в силу после перезапуска ноды.

Параметры пути: id — UUID ноды.

Тело запроса: {"cores": <целое ≥ 0>} — обязательное поле. 0 означает «все производительные ядра» (нода сама разворачивает это в список ядер при старте), а не «ни одного».

curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID/cores" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"cores":0}'
# {"id":"<UUID>","cores":0}

Ошибки

Код Тело Причина
400 {"error":"cores must be ≥ 0 (0 = all high-performance cores)"} отрицательное значение
404 {"error":"node not found"} ноды нет либо она выведена из эксплуатации (retired)
500 {"error":"cores update failed"} запись не удалась

PATCH /api/v1/nodes/{id}/tls-identity-source#

Суперадмин

Выбирает, какой TLS-идентичностью пользуется нода: собственной или синхронизированной копией сертификата Plane. Сначала Plane отправляет на ноду соответствующий блок tls { … }, и только при успехе сохраняет признак у себя — флаг не может показывать состояние, в котором нода не находится.

Параметры пути: id — UUID ноды.

Тело запроса: {"tls_identity_source": "node_managed" | "plane_shared_local"}.

Значение Что делает
plane_shared_local нода получает блок tls с cert "plane-identity.pem"; key "plane-identity.pem"; sync_from "<каталог Plane>/identity.pem"; — подходит, когда Plane и нода на одном хосте
node_managed блок tls удаляется, нода возвращается к собственному сертификату
curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID/tls-identity-source" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"tls_identity_source":"plane_shared_local"}'
# {"id":"<UUID>","tls_identity_source":"plane_shared_local"}

Ошибки

Код Тело Причина
400 {"error":"bad_tls_identity_source"} значение вне списка
409 {"error":"plane_native_identity_unavailable"} у самого Plane режим TLS не pem, не self_signed и не acme — делиться нечем
502 {"error":"node tls edit rejected (<код>): <ответ ноды>"} нода отклонила правку
404 {"error":"no such node"} нет такой записи

Если нода недоступна, ошибка возвращается наружу, а сохранённый признак остаётся прежним — повторное сохранение просто повторит попытку.

GET /api/v1/nodes/{id}/bootstrap#

Суперадмин

Повторно отдаёт текущий node.conf — то же поколение, тот же ключ. Нужен, когда ноду переустанавливают на том же железе и файл потерян.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/bootstrap" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["config"])' > node.conf

Ответ — тот же набор полей, что при создании: { "id", "name", "generation", "config" }.

Ошибки: 404 {"error":"no such node"} (в том числе для retired); 409 {"error":"node_bootstrap_not_generated"}; 503 db_unavailable.

POST /api/v1/nodes/{id}/bootstrap/rotate#

Суперадмин

Выпускает новую пару ключей управления, увеличивает bootstrap_generation, переводит ноду в pending и очищает last_seen_at. Возвращает новый node.conf.

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/bootstrap/rotate" \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["config"])' > node.conf

Ошибки: 404 {"error":"no such node"}; 503 db_unavailable.

Ротация разрывает связь с работающей нодой

Старый node.conf перестаёт аутентифицироваться сразу. Пока новый файл не положен на сервер ноды и служба не перезапущена, нода числится pending и не получает конфигурацию. Планируйте ротацию вместе с доступом к серверу ноды.

POST /api/v1/nodes/{id}/retire#

Суперадмин

Помечает намеренно выводимую из эксплуатации пустую ноду как retired и disabled.

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/retire" -H "Authorization: Bearer $TOKEN"
# {"id":"<UUID>","state":"retired"}

Ошибки

Код Тело Причина
404 {"error":"no such node"} нет такой ноды либо она не в состоянии pending/active
409 {"error":"node_has_cameras"} сначала перенесите или удалите все камеры ноды
Замена сервера — это не вывод из эксплуатации

Если вы меняете железо, но оставляете ту же ноду, не выводите её: сохраните UUID и ключ, поставьте на новый сервер тот же node.conf (или получите его через …/bootstrap). Вывод из эксплуатации нужен, когда нода больше не вернётся. Удаления ноды нет — DELETE /api/v1/nodes/{id} вернёт 405.

GET /api/v1/nodes/{id}/certs#

Суперадмин

Инвентарь сертификатов ноды (проксируется с ноды), дополненный сохранённым в Plane признаком tls_identity_source и текущей идентичностью самого Plane — чтобы в одном ответе было видно обе стороны синхронизации сертификатов.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/certs" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

К ответу ноды Plane добавляет два ключа:

  • tls_identity_sourcenode_managed или plane_shared_local;
  • plane_identity — объект с полями mode, configured_hosts, acme_directory, acme_contact, automatic_renewal, renewal_window_days, check_interval_secs, retry_interval_secs, temporary_self_signed, status_hint и certificate (null либо {fingerprint, subject, issuer, serial, hosts, not_before, not_after, days_left, renew_after}).

Ошибки: 404 {"error":"no such node"}; общие ошибки проксирования.

Сочетание "mode":"acme" и "temporary_self_signed": true означает, что настоящий сертификат ещё не выпущен и браузеры будут предупреждать; причина — в status_hint.

POST /api/v1/nodes/{id}/certs/{name}#

Суперадмин

Загружает или заменяет именованный сертификат на ноде. Тело передаётся на ноду как есть: Plane его не проверяет и не переписывает, схему определяет API ноды.

Параметры пути: id — UUID ноды; name — имя сертификата на ноде.

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/certs/le" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"cert":"-----BEGIN CERTIFICATE-----\n…","key":"-----BEGIN PRIVATE KEY-----\n…"}'

Возвращается код и тело ответа ноды без изменений. Ошибки: общие ошибки проксирования.

DELETE /api/v1/nodes/{id}/certs/{name}#

Суперадмин

Удаляет именованный сертификат на ноде (сквозной вызов к API ноды).

curl -sS -X DELETE "$BASE/api/v1/nodes/$NODE_ID/certs/le" -H "Authorization: Bearer $TOKEN"

Ошибки: общие ошибки проксирования.

GET /api/v1/nodes/{id}/license#

Суперадмин

Состояние лицензии ноды (проксируется с неё). Используется списком нод, чтобы показать, какие серверы не отдадут видео.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/license" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Словарь состояний ноды — ровно три значения:

state Смысл
licensed лицензия действует
grace льготный период
unlicensed лицензии нет — сюда же сводятся истечение срока, превышение лимита потоков и незавершённая активация; подробность в текстовой причине

Ошибки: общие ошибки проксирования.

У Plane собственной лицензии в API нет

Лицензируется нода. Путь /api/v1/license на Plane не существует и вернёт 501 {"error":"not_implemented"} — состояние лицензии запрашивайте только этим методом, для каждой ноды отдельно. См. Лицензирование.

GET /api/v1/nodes/{id}/config#

Суперадмин

Полная действующая конфигурация ноды: текст плюс его идентичность (epoch и hash). Это то, что показывает редактор конфигурации, и то, чем пользуется инициализация хранилищ.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/config" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;d=json.load(sys.stdin);print("epoch",d["epoch"],"hash",d["hash"]);print(d["text"])'

Ответ ноды возвращается как есть и содержит как минимум epoch (число), hash (16 шестнадцатеричных символов в нижнем регистре) и text (строка). Ответ может быть большим — потолок 8 МиБ.

Ошибки: общие ошибки проксирования.

PATCH /api/v1/nodes/{id}/config/globals#

Суперадмин

Правит системную (глобальную) часть конфигурации ноды. Тело передаётся на ноду без изменений, схему определяет API ноды. На практике это сравнение-с-обменом одного из двух видов:

{ "base": {"hash": "68d0b9f93ab3999d"}, "set": {"log_level": "debug"} }
{ "blocks": {"tls": "cert \"le.pem\"; key \"le.key\";"} }

Значение null вместо текста блока удаляет блок.

Ошибки: общие ошибки проксирования.

Это «аварийный люк», а не повседневный инструмент

Здесь нет типизированной проверки, и ответ ноды возвращается дословно. Для смены уровня журналов и источника TLS-идентичности пользуйтесь методами …/logging и …/tls-identity-source: они проверяют значения, ведут централизованный аудит и не пропускают наружу сырой текст конфигурации.

GET /api/v1/nodes/{id}/logging#

Суперадмин

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

curl -sS "$BASE/api/v1/nodes/$NODE_ID/logging" -H "Authorization: Bearer $TOKEN"
# {"hash":"68d0b9f93ab3999d","locked":false,"configured_level":"info"}
Поле Тип Смысл
hash string хеш конфигурации ноды, ровно 16 шестнадцатеричных символов
locked bool конфигурация ноды заблокирована на её стороне
configured_level string debug | info | warn | error

Ошибки

Код Тело Причина
409 {"error":"node_config_conflict"} конфигурация ноды изменилась
423 {"error":"node_config_locked"} конфигурация ноды заблокирована
502 {"error":"node_control_rejected"} нода не приняла управляющий токен
502 {"error":"node_logging_unavailable"} иная неудача на стороне ноды
502 {"error":"node_bad_logging_config"} ответ ноды не соответствует проекции или хеш некорректен

PATCH /api/v1/nodes/{id}/logging#

Суперадмин

Меняет уровень журналирования ноды. Это сравнение-с-обменом: вы передаёте хеш, полученный предыдущим GET, и правка применяется, только если конфигурация с тех пор не менялась.

Тело запроса

Поле Тип Обязательно Правила
level string да debug | info | warn | error, в нижнем регистре
base_hash string да hash из GET …/logging, ровно 16 шестнадцатеричных символов
HASH=$(curl -sS "$BASE/api/v1/nodes/$NODE_ID/logging" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["hash"])')

curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID/logging" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"level\":\"debug\",\"base_hash\":\"$HASH\"}"
{ "epoch": 12, "hash": "1f2e3d4c5b6a7980", "changed": true,
  "capture_level": "debug", "audit_id": "<UUID>" }

Ошибки: 400 {"error":"invalid_config_hash"}; 404 {"error":"no active node"} (ноды нет, она выведена из эксплуатации или отключена); 409 node_config_conflict — перечитайте GET …/logging и повторите; 423 node_config_locked; 502 node_control_rejected; 502 node_logging_unavailable; 502 node_bad_logging_config; 503 db_unavailable; 503 audit_unavailable.

Запись в аудит делается до обращения к ноде, поэтому попытка изменения видна в журнале даже если нода отказала.

GET /api/v1/nodes/{id}/logs#

Суперадмин

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

Параметры запроса. Строка запроса передаётся на ноду дословно (не длиннее 2048 байт), грамматику определяет нода. Если параметров нет вообще, Plane подставляет ?limit=500.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/logs?limit=200&level=warn" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Ошибки: 414 {"error":"query_too_long"} (строка запроса длиннее 2048 байт); общие ошибки проксирования.

GET /api/v1/nodes/{id}/sessions#

Суперадмин

Сеансы живых зрителей одной ноды (проксируется с неё), дополненные именами камер и субъектов из базы Plane.

Параметры запроса. Строка запроса передаётся на ноду дословно (не длиннее 2048 байт); на практике это limit, sort (например, connected_ms:desc), q, cursor.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/sessions?limit=50&sort=connected_ms:desc" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Ошибки: 414 {"error":"query_too_long"}; 503 db_unavailable (не удалось дополнить именами); общие ошибки проксирования.

GET /api/v1/sessions#

Администратор — суперадмин или админ организации.

Сеансы живых зрителей по всему флоту, безопасные по организациям. Plane обходит ноды, на которых стоят камеры вызывающего, забирает у каждой её страницу сеансов, заново проверяет каждую строку по базе и отдаёт одну общую страницу. Суперадмин видит все активные ноды, админ организации — только ноды с камерами своей организации.

Параметры запроса

Параметр Тип По умолчанию Правила
camera_id uuid только одна камера; чужая камера даёт 404 not_found (факт существования не раскрывается)
q string подстрока без учёта регистра по полям stream, protocol, client_ip, user_agent, sub, node_name, camera_name, subject_name; не длиннее 128 символов
limit integer 50 приводится к диапазону 1…100
cursor string непрозрачный курсор из предыдущего ответа, не длиннее 4096 символов
curl -sS "$BASE/api/v1/sessions?limit=50" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
{ "items": [ { "stream": "cam/<UUID>/main", "protocol": "hls", "client_ip": "203.0.113.10",
               "connected_ms": 42000, "node_id": "<UUID>", "node_name": "node-1",
               "camera_id": "<UUID>", "camera_name": "Вход" } ],
  "limit": 50, "next_cursor": null,
  "partial_nodes": [], "sampled_at": 1755000000 }

Ошибки: 400 {"error":"query_too_long"} (q длиннее 128 символов); 404 not_found (неизвестная или чужая камера); 500 {"error":"url_build_failed"}; 503 db_unavailable.

Страница — не мгновенный снимок флота

Один запрос обходит не более 4 нод с таймаутом 2 секунды на каждую. Нода, которая не ответила, пропускается и попадает в partial_nodes, а обход продолжается. Поэтому страница может вернуть меньше строк, чем limit, и при этом иметь next_cursor. Листайте до тех пор, пока next_cursor не станет null, а непустой partial_nodes считайте признаком неполных данных, а не ошибкой.

Хранилища архива#

Хранилище — это место, куда нода пишет DVR: каталог на диске или учётная запись S3 (S3 используется только как копия архива, писать в него напрямую нельзя).

Работа с хранилищами разделена на два независимых действия, и это принципиально:

  1. Запись в Plane (POST/PATCH/DELETE) описывает желаемое состояние. Она попадает в базу вместе с записью аудита, после чего служба согласования дописывает хранилище в управляемую часть конфигурации ноды. Файловая система при этом не трогается вообще.
  2. Инициализация (POST …/provision) — отдельное явное действие, которое создаёт на ноде каталог-маркер и делает диск пригодным для записи.

Пока диск не инициализирован, он не виден при размещении камер. Это самая частая причина жалобы «добавил диск, а он не появляется в списке».

Все методы раздела доступны только суперадминистратору.

Грамматика arg2 и directives#

Тело POST и PATCH одинаковое:

Поле Тип Обязательно По умолчанию
name string при создании
arg2 string да
directives string нет ""

name — обрезается по краям; не может быть пустым, . или ..; не длиннее 128 байт; только латинские буквы, цифры и символы ., _, -. Иначе — 400 bad_storage_name. В PATCH и DELETE имя берётся из пути и проверяется по тем же правилам.

arg2 выбирает тип хранилища. Это один токен: непустой, не длиннее 4096 байт, без пробелов, управляющих символов и без символов {, }, ;, #, " (иначе 400 bad_storage_arg).

Значение arg2 Тип Требования
s3 (ровно эта строка) учётная запись S3 (kind: "s3") параметры задаются директивами
любое другое путь на диске (kind: "disk") абсолютный путь: начинается с /, не равен /, без завершающей /, без //, без компонентов . и ..; иначе 400 bad_disk_storage_path

directives — небольшой текстовый блок в синтаксисе конфигурации ноды: строки вида ключ значение;, разделённые переводом строки. Каждая непустая строка обязана заканчиваться ;, ключ пишется строчными латинскими буквами и подчёркиваниями, значение — ровно один токен (не длиннее 8192 байт). Весь блок — не длиннее 32 КиБ.

Код Тело Причина
400 {"error":"storage_config_too_large"} блок директив длиннее 32 КиБ
400 {"error":"bad_storage_directive"} строка без ;, без пары «ключ-значение», недопустимый ключ или несколько токенов в значении
400 {"error":"duplicate_storage_directive"} один и тот же ключ дважды
400 {"error":"bad_storage_number"} числовая директива не разбирается или вне диапазона

Директивы диска (любой другой ключ — 400 bad_disk_storage):

Ключ Тип По умолчанию Допустимые значения
segment_secs целое 900 ровно 900, 1800 или 3600 — длительность файла-сегмента архива
evict_at_percent целое 90 0100 — заполненность диска, при которой начинается вытеснение самых старых записей

Директивы S3 (любой другой ключ — 400 bad_s3_storage):

Ключ Тип По умолчанию Допустимые значения
endpoint string — (обязателен) хост или хост:порт; префикс https:// отбрасывается, другие схемы, путь, строка запроса, якорь, логин-пароль и скобки запрещены; символы [A-Za-z0-9._-], порт 1…65535
region string us-east-1 токен не длиннее 256 байт
access_key string "" токен не длиннее 8192 байт
secret_key string "" токен не длиннее 8192 байт
addressing string "" (по умолчанию ноды) vhost или path
tls_verify string "" токен не длиннее 4096 байт

У записи S3 всегда сохраняются segment_secs: 900 и evict_at_percent: 90, у записи диска — всегда region: "us-east-1" и пустые поля S3; эти значения ни на что не влияют и просто дополняют строку.

Примеры тел запроса — диск и S3:

{ "name": "dvr1", "arg2": "/mnt/dvr",
  "directives": "segment_secs 1800;\nevict_at_percent 85;" }
{ "name": "cold", "arg2": "s3",
  "directives": "endpoint s3.example.com:9000;\nregion eu-west-1;\naccess_key EXAMPLE-ACCESS-KEY;\nsecret_key EXAMPLE-SECRET-KEY;\naddressing path;" }

GET /api/v1/nodes/{id}/storages#

Суперадмин

Список желаемых хранилищ ноды, объединённый с последней телеметрией. Хранилище, которое есть на ноде, но не описано в Plane, — это расхождение конфигурации, а не ресурс; в списке оно не появится.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/storages" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

Ответ — массив; на каждый элемент Plane накладывает свои поля поверх телеметрии:

Поле Тип Смысл
name string имя хранилища
kind string disk или s3
path string путь на диске либо endpoint S3; желаемое значение всегда важнее телеметрии
desired bool всегда true
applied bool телеметрия есть и (для диска) совпали идентификатор и путь
identity_healthy bool только для дисков: маркер на месте и читается
identity_matches bool только для дисков: маркер принадлежит именно этому хранилищу
provisioning object состояние инициализации, см. ниже

Поле provisioning принимает один из трёх видов:

{"required": false, "state": "not_required"}
{"required": true, "state": "required", "attempted": false, "in_progress": false, "lease_until": null}
{"required": false, "state": "ready", "attempted": true, "in_progress": false,
 "storage_id": "<UUID>", "device": "2049", "root_inode": "2",
 "device_format": "decimal_device_id", "desired_revision": 3, "managed_generation": 1,
 "config_epoch": 12, "config_hash": "1f2e3d4c5b6a7980", "provisioned_at": "2026-01-01T00:00:00Z"}

Ошибки: 404 {"error":"not_found"} (нет такой ноды); 503 db_unavailable.

device и root_inode — строки, а не числа

Признак device_format: "decimal_device_id" означает: это десятичные идентификаторы, записанные строкой намеренно. JavaScript округлит их при разборе как числа, и сравнение маркера перестанет работать. Сравнивайте как строки.

POST /api/v1/nodes/{id}/storages#

Суперадмин

Создаёт запись хранилища. Файловую систему ноды не трогает.

Параметры пути: id — UUID ноды. Тело запроса: см. грамматику выше; name обязателен.

Диск:

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/storages" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"dvr1","arg2":"/mnt/dvr","directives":"segment_secs 1800;\nevict_at_percent 85;"}'
# {"id":"<UUID>","name":"dvr1","convergence":"pending"}

S3 (копия архива):

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/storages" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"cold","arg2":"s3","directives":"endpoint s3.example.com:9000;\nregion eu-west-1;\naccess_key EXAMPLE-ACCESS-KEY;\nsecret_key EXAMPLE-SECRET-KEY;\naddressing path;"}'

Ответ — 201. Значение "convergence":"pending" буквально: запись в Plane есть, нода её ещё не приняла.

Ошибки

Код Тело Причина
400 bad_storage_name, bad_storage_arg, bad_disk_storage_path, bad_disk_storage, bad_s3_storage, bad_storage_directive, duplicate_storage_directive, bad_storage_number, storage_config_too_large разбор имени, arg2 или директив
400 {"error":"s3_credentials_required"} при создании S3 не заданы access_key и secret_key
404 {"error":"not_found"} нет такой ноды
409 {"error":"node_retired"} нода выведена из эксплуатации
409 {"error":"storage_limit"} достигнут предел 1024 хранилища на ноду
409 {"error":"storage_disk_path_already_exists"} этот путь уже занят другим хранилищем той же ноды
409 {"error":"already_exists"} имя занято
503 db_unavailable, audit_unavailable база или аудит недоступны

Для диска следующим шагом обязательно выполните …/provision — до этого хранилище нельзя выбрать при размещении камеры.

GET /api/v1/nodes/{id}/storages/{name}/config#

Суперадмин

Читает одно хранилище в том виде, в каком его показывает форма редактирования. Секреты возвращаются только признаками «задан / не задан».

Параметры пути: id — UUID ноды; name — имя хранилища.

curl -sS "$BASE/api/v1/nodes/$NODE_ID/storages/dvr1/config" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
{ "name": "dvr1",
  "identity_locked": true,
  "camera_refs": 12,
  "provisioning": { "required": false, "state": "ready", "…": "…" },
  "config": { "path": "/mnt/dvr", "segment_secs": 1800, "evict_at_percent": 85, "s3": null } }
Поле Смысл
identity_locked физическую идентичность менять нельзя: всегда true для диска, а также когда camera_refs > 0
camera_refs сколько камер и незавершённых удалений архива ссылаются на хранилище
config.s3 для S3: endpoint, region, access_key_configured, secret_key_configured, addressing, tls_verify

Ошибки: 404 {"error":"not_found"}; 503 db_unavailable.

Отдельного метода GET /api/v1/nodes/{id}/storages/{name} нет — он вернёт 405.

PATCH /api/v1/nodes/{id}/storages/{name}#

Суперадмин

Правит хранилище. Физическая идентичность намеренно почти неизменяема.

Параметры пути: id — UUID ноды; name — текущее имя хранилища (переименование этим методом невозможно; поле name в теле игнорируется — побеждает путь).

Тело запроса: arg2 обязателен, directives — по желанию.

curl -sS -X PATCH "$BASE/api/v1/nodes/$NODE_ID/storages/dvr1" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"arg2":"/mnt/dvr","directives":"segment_secs 3600;\nevict_at_percent 80;"}'
# {"id":"<UUID>","name":"dvr1","convergence":"pending"}

Правила изменения:

  • у диска тип и путь неизменяемы с момента создания — 409 storage_identity_immutable. Нужен другой путь — удалите запись и создайте заново; молча переназначить маркер на ноде нельзя;
  • у S3 нельзя менять endpoint, region, addressing (и тип), пока на хранилище ссылается хотя бы одна камера или незавершённое удаление архива — 409 storage_identity_in_use;
  • access_key и secret_key доступны только на запись: чтобы оставить текущий секрет, не указывайте директиву вовсе. Метод чтения их не возвращает никогда.

Ошибки: все ошибки разбора из грамматики; 404 not_found; 409 node_retired; 409 storage_identity_immutable; 409 storage_identity_in_use; 503 db_unavailable; 503 audit_unavailable.

DELETE /api/v1/nodes/{id}/storages/{name}#

Суперадмин

Удаляет запись хранилища из Plane.

curl -sS -X DELETE "$BASE/api/v1/nodes/$NODE_ID/storages/dvr1" -H "Authorization: Bearer $TOKEN"
# {"id":"<UUID>","name":"dvr1","deleted":true,"convergence":"pending"}

Ошибки

Код Тело Причина
400 {"error":"bad_storage_name"} имя в пути не проходит проверку
404 {"error":"not_found"} нет такого хранилища
409 {"error":"node_retired"} нода выведена из эксплуатации
409 {"error":"storage_in_use"} на хранилище ещё ссылается камера или незавершённое удаление архива
409 {"error":"storage_deprovision_required"} диск инициализирован или инициализация хотя бы однажды запускалась
storage_deprovision_required не снимается повторной инициализацией

Признак «попытка инициализации была» ставится навсегда — в том числе после неудачной или оборвавшейся попытки. Сделано это намеренно: на ноде мог остаться файл-маркер, и удаление одной лишь записи в Plane оставило бы его бесхозным.

Удаление блокируется, если хранилище инициализировано или инициализация когда-либо запускалась. Повторный …/provision эту блокировку не снимает — наоборот, он подтверждает инициализацию. Снять её может только явная процедура вывода хранилища из эксплуатации, которая убирает маркер на ноде и убеждается, что каталог пуст.

На практике: хранилище, которое хоть раз инициализировали, через API не удаляется. Если диск больше не нужен — снимите с него камеры (перенесите их на другое хранилище) и оставьте запись; она не мешает работе. Полное освобождение диска выполняется на самой ноде, после того как все камеры с него сняты.

POST /api/v1/nodes/{id}/storages/{name}/provision#

Суперадмин

Инициализирует диск на ноде. Перед вызовом Plane убеждается, что управляемая часть конфигурации ноды уже совпадает с желаемой, фиксирует пару (epoch, hash) в подписанном вызове, берёт аренду на 5 минут, вызывает ноду, поле за полем проверяет полученную расписку и только затем сохраняет её.

Вызов синхронный: он возвращает окончательный результат, а не задание. Тела запроса нет.

curl -sS -X POST "$BASE/api/v1/nodes/$NODE_ID/storages/dvr1/provision" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
{ "provisioned": true, "idempotent": false,
  "storage_id": "<UUID>", "name": "dvr1", "path": "/mnt/dvr",
  "device": "2049", "root_inode": "2", "device_format": "decimal_device_id",
  "managed_generation": 1, "config_epoch": 12, "config_hash": "1f2e3d4c5b6a7980",
  "provisioned_at": "2026-01-01T00:00:00Z", "audit_id": "<UUID>" }

Повторный вызов безопасен: если маркер уже на месте, ответ будет тем же, но с "idempotent": true.

Инициализация прошла успешно

В ответе "provisioned": true, а на сервере ноды в каталоге появился файл-маркер:

ls -la /mnt/dvr/
# .hastreamer-storage-identity   ← маркер: каталог принадлежит этой системе

Через API то же самое видно в списке хранилищ:

curl -sS "$BASE/api/v1/nodes/$NODE_ID/storages" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;[print(s["name"],s["provisioning"]["state"],s.get("identity_matches")) for s in json.load(sys.stdin)]'
# dvr1 ready True

После этого хранилище появляется в GET /api/v1/nodes/placement-options и его можно выбрать для камеры.

Ошибки. Каждая неудача дополнительно пишется в аудит с исходом failed и причиной.

Код Тело Причина
404 not_found на этой ноде нет такого хранилища
400 storage_provision_disk_only это учётная запись S3, инициализировать нечего
409 node_not_active ноды нет, она не active, отключена или у неё нулевое поколение
409 storage_config_not_converged нода ещё не приняла желаемую конфигурацию — подождите согласования и повторите
409 storage_provision_in_progress другая попытка держит аренду (до 5 минут)
409 storage_changed_during_provision запись изменили прямо во время вызова
409 node_generation_changed_during_provision ноду переинициализировали во время вызова
409 storage_config_changed нода ответила конфликтом конфигурации
409 storage_provision_rejected нода отклонила запрос
404 storage_deleted_during_provision запись удалили во время вызова
502 node_unreachable, node_config_unavailable, node_bad_config_identity, storage_provision_failed, node_bad_provision_receipt нода недоступна, конфигурация не читается либо расписка не прошла проверку
503 db_unavailable, audit_unavailable база или аудит недоступны

Самая частая из них — storage_config_not_converged: между созданием хранилища и инициализацией нода должна получить новую конфигурацию. Подождите несколько секунд и повторите вызов.

Подбор ноды и хранилища#

GET /api/v1/nodes/placement-options#

Администратор — суперадмин или админ организации.

Список нод и хранилищ, из которых организация может выбирать при размещении камеры: активные и невыключенные ноды, отфильтрованные списком разрешённых нод организации, а их дисковые хранилища — списком разрешённых имён хранилищ. Отвечает из кеша инвентаря, ноды не опрашиваются.

Параметры запроса: org_id — UUID, необязательный. Суперадмин может указать любую организацию (по умолчанию — своя). Для остальных чужая организация — 403 {"error":"cross_org"}.

curl -sS "$BASE/api/v1/nodes/placement-options" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
[ { "node_id": "<UUID>", "node_name": "node-1",
    "storages": ["dvr1"], "s3_storages": ["cold"] } ]
Поле Смысл
storages полностью инициализированные дисковые хранилища — рабочие цели записи
s3_storages учётные записи S3 этой ноды: только для копии архива, рабочим хранилищем быть не могут

Ошибки: 403 cross_org; 503 db_unavailable.

Диск появляется здесь только после успешного POST …/storages/{name}/provision. Неинициализированный диск для размещения не существует.

POST /api/v1/nodes/recommend#

Администратор — суперадмин или админ организации.

Подбирает лучшее место для новой камеры. Сначала отсеиваются заведомо непригодные варианты: нода должна быть активна, её телеметрия — свежее 120 секунд, нода и хранилище — в списках разрешённых для организации, а свободного места должно быть не меньше 10 ГиБ. Из оставшихся выбирается вариант с наименьшей оценкой.

Тело запроса

Поле Тип Обязательно По умолчанию Смысл
record bool нет false будет ли камера писать архив (меняет требования к хранилищу)
org_id uuid | null нет своя организация суперадмин может указать любую; для остальных чужая — 403 cross_org
node_id uuid | null нет жёстко закрепить ноду
storage string | null нет жёстко закрепить хранилище по имени
curl -sS -X POST "$BASE/api/v1/nodes/recommend" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"record":true}' | python3 -m json.tool
{ "node_id": "<UUID>", "node_name": "node-1", "storage": "dvr1",
  "score": 0.42, "telemetry_at": "2026-01-01T12:00:00Z",
  "reasons": { "cpu_pressure": 0.11, "memory_used_ratio": 0.38,
               "assigned_cameras": 120, "storage_free_ratio": 0.62,
               "storage_write_iops": 40 } }

Меньшая score — лучший вариант. Поле reasons объясняет оценку и может быть пустым объектом, если телеметрии для объяснения не хватило.

Ошибки: 409 {"error":"no_eligible_placement"} — ни один вариант не проходит жёсткие условия (чаще всего: нет инициализированного диска, телеметрия устарела или мало свободного места); 403 cross_org; 503 db_unavailable.

Маршруты, которых нет#

Эти пути часто пробуют по аналогии, но их не существует. Ответ 405 означает «путь есть, метод не поддерживается», 501 — «такого пути в API нет».

Запрос Ответ Чем пользоваться вместо
GET /api/v1/nodes/{id} 405 взять запись из GET /api/v1/nodes
DELETE /api/v1/nodes/{id} 405 POST /api/v1/nodes/{id}/retire
GET /api/v1/nodes/{id}/storages/{name} 405 GET /api/v1/nodes/{id}/storages/{name}/config
GET /api/v1/license 501 {"error":"not_implemented"} GET /api/v1/nodes/{id}/license — лицензируется нода, не Plane