Имя устройства задаётся только при создании

Имя входит в ключи бэкапов в S3 (backups/<имя>/…), поэтому после создания
не меняется (docs/changes/015):
- API: PATCH с другим именем → 400 «Имя устройства нельзя изменить»,
  то же имя допустимо;
- UI: в окне изменения поле имени только для чтения, форма изменения
  присланное имя игнорирует;
- вёрстка: минимальная ширина таблиц устройств и файлов — в узком окне
  колонка с именем не схлопывается, включается горизонтальная прокрутка.

Тесты: 12 из 12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-19 15:53:08 +03:00
1 parent 0914209bf8
commit 1e5fb17755
10 files changed
+60 -17

No files matched your search

+4 -3
View File
@@ -12,7 +12,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
## Возможности
**Устройства**
- Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения.
- Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения; **само имя задаётся только при создании и не меняется** (оно входит в ключи бэкапов).
- К устройству можно добавить **примечание** (до 500 символов): видно подсказкой при наведении на имя.
- Статус: online/offline, модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа. Колонки **Upgrade ROS** и **Upgrade FW** показывают версию, до которой можно обновиться (устройство проверяет обновления на серверах MikroTik; без интернета на устройстве — «—»).
- **Мониторинг доступности**: сервер опрашивает устройства каждые 30 с (`POLL_INTERVAL`), недоступность определяется за ~4 с на устройство (`ROS_CONNECT_TIMEOUT`); страница обновляет статусы сама, без перезагрузки.
@@ -79,7 +79,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
set -a; . ./.env; set +a
./venv/bin/uvicorn app.main:app --reload
./venv/bin/python -m pytest # 11 тестов, фоновый опрос в тестах выключен
./venv/bin/python -m pytest # 12 тестов, фоновый опрос в тестах выключен
```
## API v1
@@ -95,7 +95,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
| Метод | Путь | Назначение |
|---|---|---|
| GET/POST | `/api/v1/devices` | список (фильтры `group`, `q`, `status`, `updates`, `channel`) / добавить |
| GET/PATCH/DELETE | `/api/v1/devices/{id}` | получить / изменить / удалить |
| GET/PATCH/DELETE | `/api/v1/devices/{id}` | получить / изменить (имя менять нельзя — 400) / удалить |
| POST | `/api/v1/devices/refresh`, `/devices/{id}/refresh` | обновить статус |
| POST | `/api/v1/devices/{id}/backups` | бэкап (задача) |
| PUT | `/api/v1/devices/{id}/update/channel` | `{"channel": "stable\|long-term\|testing\|development"}` |
@@ -137,3 +137,4 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
- `012-clickable-device-name` — клик по имени устройства открывает окно изменения.
- `013-bulk-delete-backups` — выбор файлов чекбоксами и групповое удаление бэкапов.
- `014-chr-support` — поддержка CHR (нет `/system/routerboard`), строгое сравнение версий ROS, причина недоступности в таблице.
- `015-immutable-device-name` — имя устройства задаётся только при создании.
+2 -3
View File
@@ -94,9 +94,8 @@ def update_device(device_id: int, **fields) -> Device:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
if fields.get("name") and fields["name"] != d.name:
validate_name(fields["name"])
d.name = fields["name"]
if fields.get("name") and fields["name"] != d.name: # имя — часть ключей бэкапов в S3
raise ValueError("Имя устройства нельзя изменить")
for k in ("host", "port", "username", "verify_tls", "use_tls"):
if fields.get(k) is not None:
setattr(d, k, fields[k])
+9 -7
View File
@@ -320,18 +320,20 @@ async def device_edit(request: Request, device_id: int):
@router.post("/devices/{device_id}/edit", dependencies=[Depends(require_login)])
async def device_update(request: Request, device_id: int, name: str = Form(), host: str = Form(),
port: int = Form(443), username: str = Form(), password: str = Form(""),
verify_tls: bool = Form(False), use_tls: bool = Form(False),
group_id: str = Form(""), new_group: str = Form(""), note: str = Form("")):
v = dict(name=name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
async def device_update(request: Request, device_id: int, host: str = Form(), port: int = Form(443),
username: str = Form(), password: str = Form(""), verify_tls: bool = Form(False),
use_tls: bool = Form(False), group_id: str = Form(""), new_group: str = Form(""),
note: str = Form("")):
"""Имя устройства при изменении не принимается: оно задаётся только при создании."""
current = devices.get_device(device_id)
v = dict(name=current.name, host=host, port=port, username=username, group_id=group_id, new_group=new_group,
use_tls=use_tls, verify_tls=verify_tls, note=note)
try:
gid, ng = _group_choice(group_id, new_group)
devices.update_device(device_id, name=name, host=host, port=port, username=username, password=password,
devices.update_device(device_id, host=host, port=port, username=username, password=password,
verify_tls=verify_tls, use_tls=use_tls, group_id=gid, new_group=ng, note=note)
except ValueError as e:
return _device_form(request, devices.get_device(device_id), v, str(e))
return _device_form(request, current, v, str(e))
return _done(request)
+1 -1
View File
@@ -82,7 +82,7 @@
const modal = $("#modal");
if (e.detail.target.id === "modal-body") {
if (modal && !modal.open) modal.showModal();
$("#modal-body [autofocus], #modal-body input:not([type=hidden])")?.focus();
$("#modal-body [autofocus], #modal-body input:not([type=hidden]):not([readonly])")?.focus();
const sel = $("select[name=group_id][data-newgroup]"); if (sel) $("#new-group-field").hidden = sel.value !== "__new__";
}
syncSelection(); syncFilters();
+4
View File
@@ -87,6 +87,7 @@ summary.btn::-webkit-details-marker { display: none; }
/* ---------- Поля: та же форма, что у кнопок ---------- */
.field, .input { height: 36px; margin: 0; border-radius: 8px; border: 1px solid var(--line2); background-color: var(--surf); color: var(--text); font: 400 14px/1 var(--font); }
.input { width: 100%; padding: 0 12px; }
.input[readonly] { background: var(--head); color: var(--muted); cursor: not-allowed; }
textarea.input { height: 72px; padding: 10px 12px; font-family: var(--font); line-height: 1.4; resize: vertical; }
select.field { appearance: none; -webkit-appearance: none; padding: 0 36px 0 14px; font-weight: 500; cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2358657a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
@@ -149,6 +150,9 @@ table.grid { width: 100%; border-collapse: collapse; table-layout: fixed; }
.empty { height: 88px; text-align: center; color: var(--muted); }
.table-wrap { overflow-x: auto; }
@media (min-width: 1360px) { .table-wrap { overflow: visible; } }
/* при узком окне таблица прокручивается по горизонтали, а колонка с именем не схлопывается */
table.grid.devices { min-width: 1240px; }
table.grid.files { min-width: 1000px; }
.grid.jobs td { height: 48px; }
.grid.files td { height: 52px; }
.grid.groups td { height: 56px; }
+3 -2
View File
@@ -9,8 +9,9 @@
</header>
<div class="modal-body">
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
<label class="lbl"><span>Название <small>латиница, цифры, «.», «_», «-»; попадает в ключи бэкапов</small></span>
<input class="input" name="name" value="{{ v.name }}" maxlength="64" required autofocus></label>
<label class="lbl"><span>Название <small>{% if edit %}задаётся при создании и не меняется — входит в ключи бэкапов{% else %}латиница, цифры, «.», «_», «-»; попадает в ключи бэкапов{% endif %}</small></span>
{% if edit %}<input class="input" value="{{ v.name }}" readonly aria-readonly="true" tabindex="-1">
{% else %}<input class="input" name="name" value="{{ v.name }}" maxlength="64" required autofocus>{% endif %}</label>
<label class="lbl"><span>Примечание <small>необязательно</small></span>
<textarea class="input" name="note" rows="3" maxlength="500" placeholder="Например: серверная, 2 этаж; ответственный — дежурный инженер">{{ v.note }}</textarea></label>
<div class="form-row host">
+1 -1
View File
@@ -1,7 +1,7 @@
{% import "_ui.html" as ui %}
{% if oob %}<div class="sub" id="dev-sub" hx-swap-oob="true">{% include "_dev_sub.html" %}</div>{% endif %}
<div class="table-wrap">
<table class="grid">
<table class="grid devices">
<colgroup><col style="width:44px"><col><col style="width:110px"><col style="width:110px"><col style="width:100px"><col style="width:120px"><col style="width:90px"><col style="width:120px"><col style="width:100px"><col style="width:150px"><col style="width:52px"></colgroup>
<thead>
<tr>
@@ -0,0 +1,8 @@
# План: 015 — имя устройства задаётся только при создании
Запрос: имя устройства нельзя изменить; оно передаётся только при создании.
Причина важна и технически: имя входит в ключи бэкапов в S3 (`backups/<имя>/…`), группа бэкапа определяется по имени — переименование оборвало бы связь с уже загруженными файлами.
Решение:
- Сервис: `update_device` отклоняет смену имени (`ValueError`, API — HTTP 400); присланное то же имя допустимо.
- UI: в окне изменения поле имени только для чтения (нет атрибута `name`, не получает фокус, подсказка «задаётся при создании и не меняется»); маршрут изменения имя не принимает вовсе.
- Отдельно исправлено найденное при проверке: в узком окне (~1000 px) колонка с именем схлопывалась — таблицам заданы минимальные ширины (появляется горизонтальная прокрутка).
@@ -0,0 +1,7 @@
# Итоги: 015 — неизменяемое имя устройства
- API: `PATCH /api/v1/devices/{id}` с другим `name` → 400 «Имя устройства нельзя изменить»; с тем же именем и другими полями — работает.
- UI: в окне изменения имя только для чтения; форма изменения игнорирует любое присланное имя. При создании имя задаётся и проверяется как раньше.
- Верстка: у таблиц устройств и файлов минимальная ширина — при узком окне колонка с именем больше не схлопывается.
- Проверено: `pytest` 12 из 12 (новый тест: отказ API, то же имя допустимо, форма игнорирует имя); в браузере: поле readonly, ввод не меняет значение, сохранение сохраняет имя, ошибок JS нет.
- Существующие устройства не переименовывались; для смены имени нужно удалить устройство и создать заново (бэкапы в бакете останутся под старым именем).
+21
View File
@@ -274,3 +274,24 @@ async def test_chr_status_and_version_compare():
assert st["model"].startswith("CHR") and st["fw_current"] is None
assert not ros.version_newer(st["ros_latest"], st["ros_installed"]) # 7.23.7 старше 7.24
assert ros.version_newer("7.24.1", "7.24") and ros.version_newer("7.25", "7.25rc1") and not ros.version_newer("7.25rc1", "7.25")
def test_device_name_is_immutable():
"""Имя задаётся только при создании: API отклоняет смену, форма изменения имя игнорирует."""
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": "pw"})
h = {"Authorization": "Bearer test-token"}
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r2"})
assert r.status_code == 400 and "нельзя изменить" in r.text
# то же имя — допустимо (клиенты часто присылают объект целиком); остальные поля меняются
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r1", "host": "10.0.0.9"})
assert r.status_code == 200 and r.json()["name"] == "r1" and r.json()["host"] == "10.0.0.9"
# форма изменения: присланное имя игнорируется, остальное сохраняется
r = c.post(f"/devices/{d['id']}/edit", data={"name": "hacked", "host": "10.0.0.7", "port": "80", "username": "u"},
follow_redirects=False)
assert r.status_code == 303
page = c.get(f"/ui/dialog/device/{d['id']}").text
assert "readonly" in page and 'name="name"' not in page
dev = devices.get_device(d["id"])
assert (dev.name, dev.host) == ("r1", "10.0.0.7")