From 7d998066f5e05a49ff2ed43041c780d1ec244c5b Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 27 Sep 2026 18:18:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20039:=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0=20=D0=B8=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=B1=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B5=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Статус устройства (Активен / Выключен / На обслуживании), назначается вручную на экране устройств: колонка «Статус», выбор в диалоге, смена — в журнал. Миграция 0016, существующие устройства — «Активен». В списке адресов префикса колонки «Тип устройства», «Устройство», «Статус устройства» (join без N+1). pytest не прогнан: учётная запись admin из .env отключена на стенде. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++- alembic/versions/0016_device_status.py | 28 ++++++++++ app/api/v1/prefixes.py | 38 +++++++++++--- app/api/v1/refs.py | 2 +- app/models.py | 7 +++ app/schemas.py | 7 ++- docs/changes/039-device-status/PLAN.md | 63 +++++++++++++++++++++++ docs/changes/039-device-status/SUMMARY.md | 26 ++++++++++ tests/test_api.py | 15 ++++++ web/app.js | 24 ++++++--- 10 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/0016_device_status.py create mode 100644 docs/changes/039-device-status/PLAN.md create mode 100644 docs/changes/039-device-status/SUMMARY.md diff --git a/README.md b/README.md index d841a07..5ffce43 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да | Слой | Технологии | |---|---| | API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации | -| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0015`), типы `CIDR`/`INET` | +| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0016`), типы `CIDR`/`INET` | | UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет | ``` @@ -57,10 +57,14 @@ docs/changes/ планы и итоги доработок docs/reviews/ - Автовыделение вложенного префикса: система выбирает первый свободный выровненный блок заданного размера. - Изменения дерева одного VRF выполняются по одному (advisory-lock); разные VRF друг друга не блокируют. +**Устройства** +- Эксплуатационный статус устройства (изменение 039): «Активен» (по умолчанию), «Выключен», «На обслуживании». Назначается вручную на экране устройств. + **Адреса** - IP уникален в пределах VRF и хранится в самом узком содержащем его префиксе. - Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя. - Свободные адреса не хранятся, а вычисляются. В общем списке они показываются только для подсетей до /20. +- В списке адресов префикса видны тип, имя и статус привязанного устройства (изменение 039); у адресов без устройства — «—». **Ёмкость и «Обзор»** - Ёмкость префикса — размер его подсети (для IPv4 без адреса сети и broadcast). Занятость считается по всему поддереву. @@ -152,6 +156,7 @@ docs/changes/ планы и итоги доработок docs/reviews/ - Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037). - В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037). - Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера. +- «Устройства»: колонка «Статус» (бейдж) и поле «Статус» в диалоге добавления/редактирования; «Адреса» подсети: колонки «Тип устройства», «Устройство», «Статус устройства» после «Описание» (изменение 039). - Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации. - Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯». - Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей. @@ -205,6 +210,7 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q | 036 | Меню пользователя в шапке: логин с выпадающим списком | [план](docs/changes/036-user-menu/PLAN.md) · [итог](docs/changes/036-user-menu/SUMMARY.md) | | 037 | Двухфакторная аутентификация TOTP, по выбору пользователя | [план](docs/changes/037-totp-2fa/PLAN.md) · [итог](docs/changes/037-totp-2fa/SUMMARY.md) | | 038 | Тёмная тема UI | [план](docs/changes/038-dark-theme/PLAN.md) · [итог](docs/changes/038-dark-theme/SUMMARY.md) | +| 039 | Статус устройства и сведения об устройстве в списке адресов префикса | [план](docs/changes/039-device-status/PLAN.md) · [итог](docs/changes/039-device-status/SUMMARY.md) | | 040 | Название в UI: «IPAM Manager» | [план](docs/changes/040-brand-name/PLAN.md) · [итог](docs/changes/040-brand-name/SUMMARY.md) | ## Отчёты ревью diff --git a/alembic/versions/0016_device_status.py b/alembic/versions/0016_device_status.py new file mode 100644 index 0000000..e9092d4 --- /dev/null +++ b/alembic/versions/0016_device_status.py @@ -0,0 +1,28 @@ +"""Статус устройства: active | off | maintenance (изменение 039) + +Revision ID: 0016 +Revises: 0015 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0016" +down_revision = "0015" +branch_labels = None +depends_on = None + +device_status = postgresql.ENUM("active", "off", "maintenance", name="device_status") + + +def upgrade() -> None: + device_status.create(op.get_bind(), checkfirst=True) + op.add_column( + "devices", + sa.Column("status", device_status, nullable=False, server_default="active"), # существующие устройства — «Активен» + ) + + +def downgrade() -> None: + op.drop_column("devices", "status") + device_status.drop(op.get_bind(), checkfirst=True) diff --git a/app/api/v1/prefixes.py b/app/api/v1/prefixes.py index c7e9160..e2fa441 100644 --- a/app/api/v1/prefixes.py +++ b/app/api/v1/prefixes.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app import schemas as s from app.db import get_db -from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf +from app.models import Address, AddressStatus, Device, DeviceType, Organization, Prefix, PrefixStatus, User, Vrf from app.security import admin_user, current_user from app.services import ( MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete, @@ -346,14 +346,30 @@ def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), u # ------------------------------------------------------------------- addresses -def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut: +def _addr_out( + a: Address, device_name: str | None = None, device_type_name: str | None = None, device_status: str | None = None, +) -> s.AddressOut: # изменение 039: device_type_name, device_status return s.AddressOut( id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value, dns_name=a.dns_name, description=a.description, device_id=a.device_id, device_name=device_name, + device_type_name=device_type_name, device_status=device_status, note=a.note, updated_at=a.updated_at, ) +def _device_info(db: Session, device_id: int | None) -> tuple[str | None, str | None, str | None]: + """(имя, тип, статус) устройства одним запросом — для одиночных ответов (создание/правка/автоназначение адреса). + В списке (list_addresses) те же поля даёт join, без запроса на каждую строку (изменение 039).""" + if device_id is None: + return None, None, None + row = db.execute( + select(Device.name, DeviceType.name, Device.status) + .join(DeviceType, DeviceType.id == Device.device_type_id) + .where(Device.id == device_id) + ).first() + return (row[0], row[1], row[2].value) if row else (None, None, None) + + def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None): if device_id is not None: d = get_or_404(db, Device, device_id, "Устройство") @@ -396,15 +412,21 @@ def list_addresses( flt.append(Address.status == AddressStatus(status)) if q: flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q))) - stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(*flt) + # изменение 039: тип и статус устройства — тем же join, без запроса на каждую строку + stmt = ( + select(Address, Device.name, DeviceType.name, Device.status) + .outerjoin(Device, Device.id == Address.device_id) + .outerjoin(DeviceType, DeviceType.id == Device.device_type_id) + .where(*flt) + ) mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети) if mixed: - rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] + rows = [_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in db.execute(stmt.order_by(Address.address)).all()] rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address)) return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary) total = count(db, select(Address.id).where(*flt)) page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all() - return s.AddressPage(items=[_addr_out(a, dn) for a, dn in page], total=total, summary=summary) + return s.AddressPage(items=[_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in page], total=total, summary=summary) @router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201) @@ -427,7 +449,7 @@ def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), us audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address, organization_id=p.organization_id) # изменение 032: organization_id commit(db, "Адрес уже есть в этом префиксе") db.refresh(a) - return _addr_out(a) + return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039 @router.post("/prefixes/{id}/addresses/next", response_model=s.AddressOut, status_code=201) @@ -450,7 +472,7 @@ def allocate_next( audit(db, user, "address", a, "assigned", ip, organization_id=p.organization_id) # изменение 032: organization_id commit(db, "Адрес уже занят, повторите запрос") db.refresh(a) - return _addr_out(a) + return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039 @router.patch("/addresses/{id}", response_model=s.AddressOut) @@ -466,7 +488,7 @@ def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db) audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()}, organization_id=p.organization_id) # изменение 032: organization_id commit(db) db.refresh(a) - return _addr_out(a) + return _addr_out(a, *_device_info(db, a.device_id)) # изменение 039 @router.delete("/addresses/{id}", status_code=204) diff --git a/app/api/v1/refs.py b/app/api/v1/refs.py index 4d9861e..a34a149 100644 --- a/app/api/v1/refs.py +++ b/app/api/v1/refs.py @@ -228,7 +228,7 @@ def _device_outs(db: Session, devices: list[Device]) -> list[s.DeviceOut]: rows = by_device.get(d.id, []) out.append(s.DeviceOut( id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=type_names[d.device_type_id], - organization_id=d.organization_id, mac=d.mac, note=d.note, + organization_id=d.organization_id, mac=d.mac, note=d.note, status=d.status, # изменение 039 ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None, all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows), )) diff --git a/app/models.py b/app/models.py index 443d6e8..6c005c8 100644 --- a/app/models.py +++ b/app/models.py @@ -32,6 +32,12 @@ class Role(str, enum.Enum): viewer = "viewer" +class DeviceStatus(str, enum.Enum): # изменение 039: эксплуатационный статус устройства, назначается вручную + active = "active" + off = "off" + maintenance = "maintenance" + + class Organization(Base): __tablename__ = "organizations" id: Mapped[int] = mapped_column(primary_key=True) @@ -114,6 +120,7 @@ class Device(Base): organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True) mac: Mapped[str] = mapped_column(String(17), default="") note: Mapped[str] = mapped_column(Text, default="") + status: Mapped[DeviceStatus] = mapped_column(Enum(DeviceStatus, name="device_status"), default=DeviceStatus.active, server_default="active") # изменение 039 class Address(Base): diff --git a/app/schemas.py b/app/schemas.py index 411d7fa..7312f73 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -5,7 +5,7 @@ from typing import Annotated, Generic, TypeVar from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator -from app.models import AddressStatus, PrefixStatus, Role +from app.models import AddressStatus, DeviceStatus, PrefixStatus, Role T = TypeVar("T") @@ -240,6 +240,7 @@ class DeviceIn(BaseModel): organization_id: int mac: str = "" note: str = "" + status: DeviceStatus = DeviceStatus.active # изменение 039 _name_valid = field_validator("name")(_device_name) _mac_valid = field_validator("mac")(_device_mac) @@ -250,6 +251,7 @@ class DeviceUpdate(BaseModel): device_type_id: int | None = None mac: str | None = None note: str | None = None + status: DeviceStatus | None = None # изменение 039 _blank_text = field_validator("mac", "note")(_blank) # сначала null → "", затем проверка формата _name_valid = field_validator("name")(_device_name) @@ -264,6 +266,7 @@ class DeviceOut(ORM): organization_id: int mac: str note: str + status: DeviceStatus = DeviceStatus.active # изменение 039 ip_addresses: list[str] = [] first_prefix_id: int | None = None all_deprecated: bool = False @@ -388,6 +391,8 @@ class AddressOut(BaseModel): description: str = "" device_id: int | None = None device_name: str | None = None + device_type_name: str | None = None # изменение 039 + device_status: str | None = None # изменение 039 note: str = "" updated_at: datetime | None = None diff --git a/docs/changes/039-device-status/PLAN.md b/docs/changes/039-device-status/PLAN.md new file mode 100644 index 0000000..f545343 --- /dev/null +++ b/docs/changes/039-device-status/PLAN.md @@ -0,0 +1,63 @@ +# Статус устройства и сведения об устройстве в списке адресов префикса (изменение 039) + +## Context +Сейчас в списке адресов префикса (`screens.address`) не видно, к какому устройству привязан адрес, — только в диалоге правки. +У устройства нет эксплуатационного статуса. Нужно: +1. Ввести **статус устройства** — «Активен», «Выключен», «На обслуживании». Статус назначается вручную на экране устройств: + в диалоге создания и правки, в таблице устройств — новая колонка «Статус». +2. В списке адресов префикса — три новые колонки: **тип устройства**, **имя устройства**, **статус устройства** + (пусто или «—», если адрес не привязан к устройству). + +## Модель и миграция `0016_device_status.py` +- `app/models.py`: `class DeviceStatus(str, enum.Enum): active, off, maintenance`. + `Device.status: Mapped[DeviceStatus]` — `Enum(DeviceStatus, name="device_status")`, `default=active`, `server_default='active'`, NOT NULL. +- Миграция: создать тип `device_status`, добавить колонку с `server_default 'active'` — существующие устройства станут «Активен». + `downgrade`: удалить колонку и тип. + +## API +- `app/schemas.py`: + - `DeviceIn.status: DeviceStatus = DeviceStatus.active`; + - `DeviceUpdate.status: DeviceStatus | None = None`; + - `DeviceOut.status`; + - `AddressOut`: `device_type_name: str | None`, `device_status: str | None`. +- `app/api/v1/refs.py`: создание и правка устройства принимают `status`. Смена статуса попадает в журнал через существующий `apply_update` + (`device.updated` с `diff`); `_device_outs` отдаёт `status`. +- `app/api/v1/prefixes.py::list_addresses`: + - запрос `select(Address, Device.name)` расширить: `Device.status` и `DeviceType.name` через `outerjoin(DeviceType, …)`; + - `_addr_out(a, device_name, device_type_name, device_status)` — обновить все вызовы; + - остальные эндпоинты, возвращающие `AddressOut` (создание, правка, автоназначение), отдают поля так же — через общий хелпер + или отдельный запрос по `device_id`, без N+1 в списке. +- Фильтр и поиск по статусу устройства не требуются. + +## UI — `web/app.js` +- Справочник `DEVICE_STATUS = {active: ["green","Активен"], off: ["", "Выключен"], maintenance: ["amber","На обслуживании"]}` — + цвет бейджа и подпись, по образцу `ADDR_STATUS`. +- **Экран устройств** (`screens.devices`): + - колонка «Статус» с бейджем (после «Тип»), расширить `selCols` и заголовок; + - в `deviceDialog` — `fSelect("status", "Статус", …)`, по умолчанию «Активен», значение уходит в `POST`/`PATCH`. +- **Список адресов** (`screens.address`): после «Описание» колонки «Тип устройства», «Устройство», «Статус устройства». + - имя устройства ссылкой не делаем — строка и так кликабельна; у свободных и непривязанных адресов — «—»; + - статус — тем же бейджем; + - ширины подобрать так, чтобы таблица не переполнялась: «Описание» может стать уже; «Изменено» оставить. +- Цвета — только через существующие классы бейджей и токены тёмной темы (изменение 038), без литералов. + +## Тесты (минимально) +Одна проверка в существующем тесте `tests/test_api.py`, например в `test_in_use_objects_cannot_be_deleted` или рядом, без нового файла: +- создать устройство со `status="maintenance"`, привязать к нему адрес; +- `GET /prefixes/{id}/addresses` → у адреса `device_name`, `device_type_name`, `device_status == "maintenance"`; +- `PATCH /devices/{id} {"status": "off"}` → 200, `status == "off"`. + +## Документация +- `README.md`: модель данных — статус устройства; «Интерфейс» — колонки; миграции `0001`–`0016`; строка 039 в истории. +- `SUMMARY.md` — по завершении (моя часть). + +## Исполнение +По принятой схеме: код, миграцию, UI и тест пишет агент на Sonnet, он же пересобирает стенд с `--force-recreate`; тесты он не запускает. +Моя часть — ревью, `pytest`, проверка миграции и API. UI проверяет пользователь. + +## Проверка +- `alembic current` = `0016`, `alembic check` чисто; откат до 0015 и повторный upgrade. +- Существующие устройства после миграции — `active`. +- `pytest -q` — все зелёные. +- API: статус меняется и пишется в журнал (`device.updated`, `diff.status`); адреса отдают три новых поля; + у свободных и непривязанных адресов — `null`. diff --git a/docs/changes/039-device-status/SUMMARY.md b/docs/changes/039-device-status/SUMMARY.md new file mode 100644 index 0000000..c7c874e --- /dev/null +++ b/docs/changes/039-device-status/SUMMARY.md @@ -0,0 +1,26 @@ +# Итог: статус устройства и сведения об устройстве в списке адресов (изменение 039) + +План: `PLAN.md`. У устройства появился статус «Активен» / «Выключен» / «На обслуживании», назначаемый вручную на экране устройств. +В списке адресов префикса — колонки «Тип устройства», «Устройство», «Статус устройства». + +## Что сделано +| Файл | Изменение | +|---|---| +| `app/models.py`, `alembic/versions/0016_device_status.py` | `DeviceStatus` (`active`/`off`/`maintenance`), `devices.status` NOT NULL, `server_default 'active'` — существующие устройства стали «Активен» | +| `app/schemas.py` | `DeviceIn.status` (по умолчанию `active`), `DeviceUpdate.status`, `DeviceOut.status`; `AddressOut.device_type_name`, `device_status` | +| `app/api/v1/refs.py` | `status` в ответах об устройствах; смена статуса — в журнал через `apply_update` (`device.updated`, `diff.status`) | +| `app/api/v1/prefixes.py` | `list_addresses`: имя, тип и статус устройства одним `outerjoin` на страницу; одиночные ответы (создание/правка/автоназначение адреса) — хелпер `_device_info`, один запрос, раньше `device_name` там не отдавался | +| `web/app.js` | `DEVICE_STATUS` (бейджи green / нейтральный / amber); «Устройства»: колонка «Статус», выбор статуса в диалоге; «Адреса»: три колонки, «—» у свободных и непривязанных адресов | +| `tests/test_api.py` | `test_device_status_on_address`: устройство `maintenance` → поля в списке адресов; `PATCH status=off` | +| `README.md` | Модель данных (устройства), «Интерфейс», миграции `0001`–`0016`, строка 039 | + +## Проверки +- Миграция 0016 применена, `alembic check` чисто, откат до 0015 и повторный upgrade — без ошибок; 3 существующих устройства — `active`. +- Статус в API — строка (`"maintenance"`), не представление enum (проверено сериализацией `AddressOut`). +- `node --check`, `py_compile`, `import app.main` — чисто; стенд пересобран (`--force-recreate`), healthy, отдаёт новый `app.js`. +- Новых литеральных цветов в `app.js` нет (бейджи — существующие классы, тёмная тема не затронута). + +## Не проверено +- **`pytest` не прогнан**, в том числе новый `test_device_status_on_address`: учётная запись `admin` из `.env` (фикстура `client`) + отключена пользователем `tstark` через UI (13:58). Решение о её включении или других учётных данных для тестов — за пользователем. +- UI — ручная проверка пользователя. diff --git a/tests/test_api.py b/tests/test_api.py index 2640439..5ff5394 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -64,6 +64,21 @@ def test_in_use_objects_cannot_be_deleted(client, org): client.delete(f"/users/{uid}") +def test_device_status_on_address(client, org): + """Изменение 039: статус устройства отдаётся в списке адресов префикса и меняется через PATCH /devices/{id}.""" + p = _prefix(client, org, "10.205.0.0/24").json() + t = client.get("/device-types").json()["items"][0] + dev = client.post("/devices", json={"name": "svc-1.internal", "device_type_id": t["id"], "organization_id": org["id"], "status": "maintenance"}).json() + assert dev["status"] == "maintenance" + client.post(f"/prefixes/{p['id']}/addresses", json={"address": "10.205.0.1", "device_id": dev["id"]}) + page = client.get(f"/prefixes/{p['id']}/addresses").json() + a = next(x for x in page["items"] if x["address"] == "10.205.0.1") + assert a["device_name"] == "svc-1.internal" and a["device_type_name"] == t["name"] and a["device_status"] == "maintenance" + + upd = client.patch(f"/devices/{dev['id']}", json={"status": "off"}) + assert upd.status_code == 200 and upd.json()["status"] == "off" + + def test_delete_organization(client, org): _prefix(client, org, "10.206.0.0/24") busy = client.delete(f"/organizations/{org['id']}") # с префиксом — нельзя diff --git a/web/app.js b/web/app.js index bcfdc94..4af3b86 100644 --- a/web/app.js +++ b/web/app.js @@ -96,6 +96,7 @@ function toast(msg, err = false) { const badge = (cls, text) => `${esc(text)}`; const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] }; const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] }; +const DEVICE_STATUS = { active: ["green", "Активен"], off: ["", "Выключен"], maintenance: ["amber", "На обслуживании"] }; // изменение 039 // изменение 038: цвета — CSS-токены вместо литералов; третий элемент — признак «порог не превышен» (обычный/приглушённый вид) const utilColor = (p) => (p >= 90 ? ["var(--danger-strong)", "var(--danger)", false] : p >= 75 ? ["var(--warn-strong)", "var(--warn)", false] : ["var(--primary)", "var(--muted)", true]); const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => ``; @@ -333,39 +334,43 @@ screens.devices = async () => { ]); S.types = types.items; S.rows = list.items; - const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px"); + const cols = selCols("minmax(200px,1.6fr) 170px 140px 110px minmax(200px,1.6fr) 44px"); // изменение 039: колонка «Статус» setSelectable(list.items.map((d) => d.id)); const q = S.q || ""; const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))]; const rows = list.items.map((d, n) => { const dep = d.all_deprecated; + const [dc, dt] = DEVICE_STATUS[d.status]; // изменение 039 const ips = d.ip_addresses.length ? `${d.ip_addresses.length}${d.ip_addresses.length > 1 ? `+${d.ip_addresses.length - 1}` : ""}` : `0`; return `
${selCell(d.id)}${esc(d.name)} -${esc(d.device_type_name)}${ips}${esc(d.note)} +${esc(d.device_type_name)}${badge(dc, dt)}${ips}${esc(d.note)} ${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}
`; }).join(""); const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}
-${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}
${selAllCell()}УстройствоТипIP-адресовЗаметки
+${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}
${selAllCell()}УстройствоТипСтатусIP-адресовЗаметки
${rows || '
Устройств нет
'}
Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}
`); }; function deviceDialog(d) { const edit = !!d; S.dialog = async (v) => { - const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note } + const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note, status: v.status } : { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) }; await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body }); toast(edit ? "Устройство сохранено" : "Устройство добавлено"); await draw(); }; + // изменение 039: статус устройства, по умолчанию «Активен» + const statusOptions = Object.entries(DEVICE_STATUS).map(([value, [, label]]) => ({ value, label })); openDialog({ title: edit ? "Редактирование устройства" : "Новое устройство", body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })} -
${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}
+
${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fSelect("status", "Статус", statusOptions, d?.status ?? "active")}
+${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })} ${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)} ${fArea("note", "Заметки", { value: d?.note })}
IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».
`), @@ -566,15 +571,18 @@ screens.address = async (id) => { const chain = []; for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x); const sm = page.summary; - const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px"); + // изменение 039: после «Описание» — «Тип устройства», «Устройство», «Статус устройства»; «Описание» и адресные колонки сужены, «Изменён» без изменений + const cols = selCols("minmax(120px,0.9fr) minmax(110px,0.8fr) minmax(110px,1fr) 120px minmax(130px,1fr) 140px 110px 160px 44px"); setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи const rows = page.items.map((a, n) => { const [c, t] = ADDR_STATUS[a.status]; const free = a.status === "free"; const key = "adr-row-" + (a.id ?? a.address); const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }]; + const devStatus = a.device_status ? badge(...DEVICE_STATUS[a.device_status]) : `—`; // изменение 039 return `
${free ? selBlank() : selCell(a.id)}${esc(a.address)} -${esc(a.dns_name || "—")}${esc(a.description || (free ? "—" : ""))}${badge(c, t)} +${esc(a.dns_name || "—")}${esc(a.description || (free ? "—" : ""))} +${esc(a.device_type_name || "—")}${esc(a.device_name || "—")}${devStatus}${badge(c, t)} ${a.updated_at ? fmtDate(a.updated_at) : "—"}${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}
`; }).join(""); const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0); @@ -587,7 +595,7 @@ screens.address = async (id) => {
Назначено ${sm.assigned}Зарезервировано ${sm.reserved}Свободно ${sm.free}
${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}
-${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}
${selAllCell()}IP-адресDNS-имяОписаниеСтатусИзменён (UTC)
+${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}
${selAllCell()}IP-адресDNS-имяОписаниеТип устройстваУстройствоСтатус устройстваСтатусИзменён (UTC)
${rows || '
Адресов нет
'}
Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}
`); }; function addressDialog(a, presetIp = "") {