Имя устройства задаётся только при создании
Имя входит в ключи бэкапов в 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:
1 parent
0914209bf8
commit
1e5fb17755
10 files changed
+60
-17
No files matched your search
@@ -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` — имя устройства задаётся только при создании.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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 нет.
|
||||
- Существующие устройства не переименовывались; для смены имени нужно удалить устройство и создать заново (бэкапы в бакете останутся под старым именем).
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user