Задача 039: статус устройства и сведения об устройстве в списке адресов
Статус устройства (Активен / Выключен / На обслуживании), назначается вручную на экране устройств: колонка «Статус», выбор в диалоге, смена — в журнал. Миграция 0016, существующие устройства — «Активен». В списке адресов префикса колонки «Тип устройства», «Устройство», «Статус устройства» (join без N+1). pytest не прогнан: учётная запись admin из .env отключена на стенде. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aeb89dbded
commit
7d998066f5
10 files changed
+199
-19
No files matched your search
@@ -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) |
|
||||||
|
|
||||||
## Отчёты ревью
|
## Отчёты ревью
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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 — ручная проверка пользователя.
|
||||||
@@ -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
@@ -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 = "") {
|
||||||
|
|||||||
Reference in new issue
Block a user