diff --git a/README.md b/README.md index 610eee7..aa52b14 100644 --- a/README.md +++ b/README.md @@ -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` — имя устройства задаётся только при создании. diff --git a/app/services/devices.py b/app/services/devices.py index 1e7ccba..d76d4c4 100644 --- a/app/services/devices.py +++ b/app/services/devices.py @@ -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]) diff --git a/app/ui/routes.py b/app/ui/routes.py index 0301b77..8f55ae6 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -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) diff --git a/app/ui/static/app.js b/app/ui/static/app.js index 6483391..d7b1ad5 100644 --- a/app/ui/static/app.js +++ b/app/ui/static/app.js @@ -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(); diff --git a/app/ui/static/style.css b/app/ui/static/style.css index f741fd4..42f789e 100644 --- a/app/ui/static/style.css +++ b/app/ui/static/style.css @@ -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; } diff --git a/app/ui/templates/_device_form.html b/app/ui/templates/_device_form.html index a0c6b79..cc69e57 100644 --- a/app/ui/templates/_device_form.html +++ b/app/ui/templates/_device_form.html @@ -9,8 +9,9 @@