Ноды и хранилища
Создание ноды и получение 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.
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_source—node_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 |
лицензии нет — сюда же сводятся истечение срока, превышение лимита потоков и незавершённая активация; подробность в текстовой причине |
Ошибки: общие ошибки проксирования.
Лицензируется нода. Путь /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 используется только как копия архива, писать в него напрямую нельзя).
Работа с хранилищами разделена на два независимых действия, и это принципиально:
- Запись в Plane (
POST/PATCH/DELETE) описывает желаемое состояние. Она попадает в базу вместе с записью аудита, после чего служба согласования дописывает хранилище в управляемую часть конфигурации ноды. Файловая система при этом не трогается вообще. - Инициализация (
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 |
0…100 — заполненность диска, при которой начинается вытеснение самых старых записей |
Директивы 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 |