Задача 039: статус устройства и сведения об устройстве в списке адресов

Статус устройства (Активен / Выключен / На обслуживании), назначается вручную на экране
устройств: колонка «Статус», выбор в диалоге, смена — в журнал. Миграция 0016,
существующие устройства — «Активен». В списке адресов префикса колонки «Тип устройства»,
«Устройство», «Статус устройства» (join без N+1). pytest не прогнан: учётная запись admin
из .env отключена на стенде.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 18:18:33 +03:00
1 parent aeb89dbded
commit 7d998066f5
10 files changed
+199 -19

No files matched your search

+7 -1
View File
@@ -29,7 +29,7 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да
| Слой | Технологии | | Слой | Технологии |
|---|---| |---|---|
| API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации | | 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 хранятся локально, внешних зависимостей нет | | UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
``` ```
@@ -57,10 +57,14 @@ docs/changes/ планы и итоги доработок docs/reviews/
- Автовыделение вложенного префикса: система выбирает первый свободный выровненный блок заданного размера. - Автовыделение вложенного префикса: система выбирает первый свободный выровненный блок заданного размера.
- Изменения дерева одного VRF выполняются по одному (advisory-lock); разные VRF друг друга не блокируют. - Изменения дерева одного VRF выполняются по одному (advisory-lock); разные VRF друг друга не блокируют.
**Устройства**
- Эксплуатационный статус устройства (изменение 039): «Активен» (по умолчанию), «Выключен», «На обслуживании». Назначается вручную на экране устройств.
**Адреса** **Адреса**
- IP уникален в пределах VRF и хранится в самом узком содержащем его префиксе. - IP уникален в пределах VRF и хранится в самом узком содержащем его префиксе.
- Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя. - Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя.
- Свободные адреса не хранятся, а вычисляются. В общем списке они показываются только для подсетей до /20. - Свободные адреса не хранятся, а вычисляются. В общем списке они показываются только для подсетей до /20.
- В списке адресов префикса видны тип, имя и статус привязанного устройства (изменение 039); у адресов без устройства — «—».
**Ёмкость и «Обзор»** **Ёмкость и «Обзор»**
- Ёмкость префикса — размер его подсети (для IPv4 без адреса сети и broadcast). Занятость считается по всему поддереву. - Ёмкость префикса — размер его подсети (для IPv4 без адреса сети и broadcast). Занятость считается по всему поддереву.
@@ -152,6 +156,7 @@ docs/changes/ планы и итоги доработок docs/reviews/
- Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037). - Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037).
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037). - В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
- Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера. - Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера.
- «Устройства»: колонка «Статус» (бейдж) и поле «Статус» в диалоге добавления/редактирования; «Адреса» подсети: колонки «Тип устройства», «Устройство», «Статус устройства» после «Описание» (изменение 039).
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации. - Переключатель организации — только у `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) | | 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) | | 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) | | 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) | | 040 | Название в UI: «IPAM Manager» | [план](docs/changes/040-brand-name/PLAN.md) · [итог](docs/changes/040-brand-name/SUMMARY.md) |
## Отчёты ревью ## Отчёты ревью
+28
View File
@@ -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)
+30 -8
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app import schemas as s from app import schemas as s
from app.db import get_db 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.security import admin_user, current_user
from app.services import ( 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, 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 # ------------------------------------------------------------------- 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( return s.AddressOut(
id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value, 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, 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, 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): def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None):
if device_id is not None: if device_id is not None:
d = get_or_404(db, Device, device_id, "Устройство") d = get_or_404(db, Device, device_id, "Устройство")
@@ -396,15 +412,21 @@ def list_addresses(
flt.append(Address.status == AddressStatus(status)) flt.append(Address.status == AddressStatus(status))
if q: if q:
flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, 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 # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети) mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
if mixed: 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)) 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) return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
total = count(db, select(Address.id).where(*flt)) total = count(db, select(Address.id).where(*flt))
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all() 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) @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 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, "Адрес уже есть в этом префиксе") commit(db, "Адрес уже есть в этом префиксе")
db.refresh(a) 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) @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 audit(db, user, "address", a, "assigned", ip, organization_id=p.organization_id) # изменение 032: organization_id
commit(db, "Адрес уже занят, повторите запрос") commit(db, "Адрес уже занят, повторите запрос")
db.refresh(a) 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) @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 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) commit(db)
db.refresh(a) 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) @router.delete("/addresses/{id}", status_code=204)
+1 -1
View File
@@ -228,7 +228,7 @@ def _device_outs(db: Session, devices: list[Device]) -> list[s.DeviceOut]:
rows = by_device.get(d.id, []) rows = by_device.get(d.id, [])
out.append(s.DeviceOut( 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], 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, 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), all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows),
)) ))
+7
View File
@@ -32,6 +32,12 @@ class Role(str, enum.Enum):
viewer = "viewer" viewer = "viewer"
class DeviceStatus(str, enum.Enum): # изменение 039: эксплуатационный статус устройства, назначается вручную
active = "active"
off = "off"
maintenance = "maintenance"
class Organization(Base): class Organization(Base):
__tablename__ = "organizations" __tablename__ = "organizations"
id: Mapped[int] = mapped_column(primary_key=True) 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) organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True)
mac: Mapped[str] = mapped_column(String(17), default="") mac: Mapped[str] = mapped_column(String(17), default="")
note: Mapped[str] = mapped_column(Text, 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): class Address(Base):
+6 -1
View File
@@ -5,7 +5,7 @@ from typing import Annotated, Generic, TypeVar
from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator 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") T = TypeVar("T")
@@ -240,6 +240,7 @@ class DeviceIn(BaseModel):
organization_id: int organization_id: int
mac: str = "" mac: str = ""
note: str = "" note: str = ""
status: DeviceStatus = DeviceStatus.active # изменение 039
_name_valid = field_validator("name")(_device_name) _name_valid = field_validator("name")(_device_name)
_mac_valid = field_validator("mac")(_device_mac) _mac_valid = field_validator("mac")(_device_mac)
@@ -250,6 +251,7 @@ class DeviceUpdate(BaseModel):
device_type_id: int | None = None device_type_id: int | None = None
mac: str | None = None mac: str | None = None
note: str | None = None note: str | None = None
status: DeviceStatus | None = None # изменение 039
_blank_text = field_validator("mac", "note")(_blank) # сначала null → "", затем проверка формата _blank_text = field_validator("mac", "note")(_blank) # сначала null → "", затем проверка формата
_name_valid = field_validator("name")(_device_name) _name_valid = field_validator("name")(_device_name)
@@ -264,6 +266,7 @@ class DeviceOut(ORM):
organization_id: int organization_id: int
mac: str mac: str
note: str note: str
status: DeviceStatus = DeviceStatus.active # изменение 039
ip_addresses: list[str] = [] ip_addresses: list[str] = []
first_prefix_id: int | None = None first_prefix_id: int | None = None
all_deprecated: bool = False all_deprecated: bool = False
@@ -388,6 +391,8 @@ class AddressOut(BaseModel):
description: str = "" description: str = ""
device_id: int | None = None device_id: int | None = None
device_name: str | None = None device_name: str | None = None
device_type_name: str | None = None # изменение 039
device_status: str | None = None # изменение 039
note: str = "" note: str = ""
updated_at: datetime | None = None updated_at: datetime | None = None
+63
View File
@@ -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`.
+26
View File
@@ -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 — ручная проверка пользователя.
+15
View File
@@ -64,6 +64,21 @@ def test_in_use_objects_cannot_be_deleted(client, org):
client.delete(f"/users/{uid}") 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): def test_delete_organization(client, org):
_prefix(client, org, "10.206.0.0/24") _prefix(client, org, "10.206.0.0/24")
busy = client.delete(f"/organizations/{org['id']}") # с префиксом — нельзя busy = client.delete(f"/organizations/{org['id']}") # с префиксом — нельзя
+16 -8
View File
@@ -96,6 +96,7 @@ function toast(msg, err = false) {
const badge = (cls, text) => `<span class="badge ${cls}">${esc(text)}</span>`; const badge = (cls, text) => `<span class="badge ${cls}">${esc(text)}</span>`;
const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] }; const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] };
const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] }; const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] };
const DEVICE_STATUS = { active: ["green", "Активен"], off: ["", "Выключен"], maintenance: ["amber", "На обслуживании"] }; // изменение 039
// изменение 038: цвета — CSS-токены вместо литералов; третий элемент — признак «порог не превышен» (обычный/приглушённый вид) // изменение 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 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 = "" } = {}) => `<button type="button" class="btn ${cls}" data-action="${action}" ${data}>${icon}${label}</button>`; const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => `<button type="button" class="btn ${cls}" data-action="${action}" ${data}>${icon}${label}</button>`;
@@ -333,39 +334,43 @@ screens.devices = async () => {
]); ]);
S.types = types.items; S.types = types.items;
S.rows = list.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)); setSelectable(list.items.map((d) => d.id));
const q = S.q || ""; const q = S.q || "";
const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))]; const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))];
const rows = list.items.map((d, n) => { const rows = list.items.map((d, n) => {
const dep = d.all_deprecated; const dep = d.all_deprecated;
const [dc, dt] = DEVICE_STATUS[d.status]; // изменение 039
const ips = d.ip_addresses.length const ips = d.ip_addresses.length
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>` ? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
: `<span class="muted" style="font-size:13px">0</span>`; : `<span class="muted" style="font-size:13px">0</span>`;
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span> return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span> <span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span><span>${badge(dc, dt)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
<span class="cell-actions rel">${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")}</span></div>`; <span class="cell-actions rel">${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")}</span></div>`;
}).join(""); }).join("");
const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации 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)} return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
<div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div> <div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div> <div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div> ${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>Статус</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`); ${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
}; };
function deviceDialog(d) { function deviceDialog(d) {
const edit = !!d; const edit = !!d;
S.dialog = async (v) => { 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) }; : { ...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 }); await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body });
toast(edit ? "Устройство сохранено" : "Устройство добавлено"); toast(edit ? "Устройство сохранено" : "Устройство добавлено");
await draw(); await draw();
}; };
// изменение 039: статус устройства, по умолчанию «Активен»
const statusOptions = Object.entries(DEVICE_STATUS).map(([value, [, label]]) => ({ value, label }));
openDialog({ openDialog({
title: edit ? "Редактирование устройства" : "Новое устройство", title: edit ? "Редактирование устройства" : "Новое устройство",
body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })} body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })}
<div class="grid2">${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 })}</div> <div class="grid2">${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fSelect("status", "Статус", statusOptions, d?.status ?? "active")}</div>
${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)} ${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
${fArea("note", "Заметки", { value: d?.note })} ${fArea("note", "Заметки", { value: d?.note })}
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`), <div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
@@ -566,15 +571,18 @@ screens.address = async (id) => {
const chain = []; const chain = [];
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x); for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
const sm = page.summary; 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)); // у «Свободен» нет записи setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
const rows = page.items.map((a, n) => { const rows = page.items.map((a, n) => {
const [c, t] = ADDR_STATUS[a.status]; const [c, t] = ADDR_STATUS[a.status];
const free = a.status === "free"; const free = a.status === "free";
const key = "adr-row-" + (a.id ?? a.address); 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 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]) : `<span class="muted">—</span>`; // изменение 039
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span> return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span> <span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="ell muted">${esc(a.description || (free ? "—" : ""))}</span>
<span class="muted">${esc(a.device_type_name || "—")}</span><span class="ell muted">${esc(a.device_name || "—")}</span><span>${devStatus}</span><span>${badge(c, t)}</span>
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`; <span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
}).join(""); }).join("");
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0); const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
@@ -587,7 +595,7 @@ screens.address = async (id) => {
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:var(--primary)"></div><div style="width:${pct(sm.reserved)}%;background:var(--warn-strong)"></div></div></div> <div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:var(--primary)"></div><div style="width:${pct(sm.reserved)}%;background:var(--warn-strong)"></div></div></div>
<div class="legend"><span><i style="background:var(--primary)"></i>Назначено ${sm.assigned}</span><span><i style="background:var(--warn-strong)"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:var(--line)"></i>Свободно ${sm.free}</span></div></div> <div class="legend"><span><i style="background:var(--primary)"></i>Назначено ${sm.assigned}</span><span><i style="background:var(--warn-strong)"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:var(--line)"></i>Свободно ${sm.free}</span></div></div>
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div> <div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div> ${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Тип устройства</span><span>Устройство</span><span>Статус устройства</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`); ${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
}; };
function addressDialog(a, presetIp = "") { function addressDialog(a, presetIp = "") {