Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов

006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи»,
    события журнала user.*, защита от отключения/удаления себя и последнего админа.
007 Исправление удаления организации: VRF удаляются явным DELETE до организации
    (без relationship() порядок DELETE не гарантирован → ложный 409).
008 Журнал фиксирует отказы в удалении (<entity>.delete_blocked) со списком
    мешающих объектов в «Данных»: организация, VRF, тип устройства, префикс, пользователь.
009 Выбор строк чекбоксами и групповые операции в UI (удаление, смена типа устройств,
    статус префиксов и адресов, доступ пользователей); цикл запросов из UI, итог и список отказов.
010 Автовыделение следующего вложенного префикса: POST/GET /prefixes/{id}/subnets/next,
    первый свободный выровненный блок; пункт «Добавить вложенный (авто)» в меню префикса.

Тесты: 14 (добавлены сценарии для 006, 007/008, 010); исправлена нестабильность
тестов журнала (IPv6-группы с ведущими нулями нормализуются PostgreSQL).
Документация: README.md, docs/changes/006-010 (планы и итоги).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-26 13:25:33 +03:00
1 parent a846d30872
commit cd09ef0805
23 files changed
+988 -49

No files matched your search

+22 -2
View File
@@ -37,15 +37,30 @@ alembic/ scripts/{gen_env,seed_demo}.py tests/ docs/changes/
имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД.
- Смена VRF у префикса (`PATCH /prefixes/{id}` с `vrf_id`) — только среди VRF той же организации; переносится префикс вместе с вложенными,
дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422.
- VRF, тип устройства, организация с зависимыми объектами не удаляются (409).
- Автовыделение вложенного префикса (изменение 010): `POST /prefixes/{id}/subnets/next` с `length` (например 30) создаёт дочерний префикс в первом свободном выровненном блоке родителя (учитываются вложенные префиксы
и адреса родителя); `GET` с тем же путём и `?length=` — предпросмотр. Нет места → 409, недопустимый размер → 422. В UI — пункт «Добавить вложенный (авто)» в меню «⋯» префикса.
- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). У организации без префиксов, устройств и операторов
служебный VRF `default` удаляется вместе с ней (исправлено в изменении 007: раньше такое удаление давало 409).
## API (`/api/v1`)
`POST /auth/login` · `GET /auth/me` · `GET /overview`
CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}`
CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}`, `/users`
Адреса префикса: `GET|POST /prefixes/{id}/addresses` (`status`, `q`, `limit`, `offset`), `POST …/addresses/next` — автоназначение из пула.
Ошибки: `{code, message, fields}` (для блокировок/лимитов — дополнительные поля `attempts_left`, `retry_after_seconds`). Каждое изменение пишется в `audit_log`.
### Пользователи и роли
- `GET|POST /users`, `PATCH|DELETE /users/{id}` — управление учётными записями, только `admin`: логин (3–100 символов, латиница,
цифры, `. _ -`), роль, доступ и пароль (при сбросе администратором). Логин после создания не меняется — он же `sub` в токене.
- `POST /users/me/password {current_password, new_password}` — смена своего пароля, доступна любой роли; неверный текущий пароль → 403.
- Занятый логин (в том числе в другом регистре) → 409; служебные логины `system` и `anonymous`, короткий логин или пароль → 422.
- Свою учётную запись нельзя понизить, отключить или удалить, как и последнего активного администратора → 409.
- Отключение действует немедленно (токен проверяется по `users.is_active` на каждом запросе), а смена пароля уже выданные токены
не отзывает — они живут до истечения `JWT_TTL_MINUTES`. Роль `viewer` видит реестр и журнал, но любые изменения получает с 403.
- Изменения пишутся в журнал: `user.created`, `user.updated`, `user.password_reset`, `user.deleted` (значения паролей не сохраняются).
### Журнал
- Отказ в удалении по бизнес-правилу (409: организация, VRF, тип устройства, префикс с адресами, пользователь) фиксируется событием `<сущность>.delete_blocked` (изменение 008);
в «Данных» записи — `reason` и `blocked_by` со списками мешающих объектов (`total` и до 20 названий: префиксы, устройства, операторы, адреса).
- `GET /audit` — поиск и фильтры: `q` (сообщение, метка объекта, начало ID записи), `event_type` (`prefix.created`), `entity_type`, `actor` (`ui:admin`, `system`, `anonymous`), `date_from`/`date_to` (UTC), `limit`/`offset`; `GET /audit/summary`, `/audit/facets`, `/audit/{uid}`.
- `GET|PUT /journal/settings` — ротация: `retention_days` (по умолчанию 90) и `max_entries` (100 000), `0` — без ограничения. Ротация идёт раз в час и сразу при сохранении настроек
(advisory-lock защищает от параллельного запуска); каждая ротация с удалениями фиксируется записью `journal.rotated`. Запись — только admin.
@@ -57,9 +72,14 @@ CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixe
- В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`) и служебные события (`journal.*`, актор `system`).
## Поведение таблиц UI
Администратор может выбирать строки чекбоксами (в шапке — «выбрать все») на экранах «Организации», «Операторы», «Устройства», «Префиксы» (листовые), «Адреса» (кроме «Свободен») и «Пользователи» (кроме себя);
в «Журнале» выбора нет. Панель над таблицей: «Удалить» везде, «Сменить тип» (устройства), «Статус» (префиксы, адреса), «Разрешить/Отключить доступ» (пользователи). Операции идут по одному запросу
на объект (изменение 009): итог «выполнено N из M», отказы (зависимые объекты, свой аккаунт, последний администратор) показаны списком с причиной, попадают в журнал как `*.delete_blocked`,
и остаются выбранными. Префиксы удаляются без `force`: префикс с адресами удаляется из одиночного меню строки.
Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации;
«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки;
Ctrl/Shift+клик и выделение текста тоже игнорируются.
Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке.
## Тесты
Идут против приложения в контейнерах, учётные данные берутся из `.env`:
+44 -3
View File
@@ -9,7 +9,8 @@ from app.db import get_db
from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf
from app.security import admin_user, current_user
from app.services import (
MAX_CAPACITY, apply_update, audit, capacity, commit, count, flush, get_or_404, next_free, utilization,
MAX_CAPACITY, apply_update, audit, blockers, capacity, commit, count, flush, get_or_404, next_free, next_free_subnet, refuse_delete,
utilization,
)
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
@@ -188,6 +189,45 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
return _prefix_outs(db, [p])[0]
def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]:
"""(свободный блок | None, min длина, max длина) для вложенного префикса в parent."""
net = ipaddress.ip_network(str(parent.prefix))
lo, hi = net.prefixlen + 1, net.max_prefixlen
if not lo <= length <= hi:
raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес")
busy = [(int(n.network_address), int(n.broadcast_address)) for n in
(ipaddress.ip_network(str(c)) for c in db.scalars(
select(Prefix.prefix).where(Prefix.vrf_id == parent.vrf_id, Prefix.id != parent.id, Prefix.prefix.op("<<")(str(parent.prefix)))))]
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == parent.id))]
return next_free_subnet(str(parent.prefix), length, busy), lo, hi
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)):
"""Предпросмотр: какой блок будет выделен, без создания."""
found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length)
return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi)
@router.post("/prefixes/{id}/subnets/next", response_model=s.PrefixOut, status_code=201)
def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
"""Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя."""
parent = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # сериализуем параллельные выделения из одного родителя
if parent is None:
raise HTTPException(404, "Префикс не найден")
found, _, _ = _find_subnet(db, parent, body.length)
if found is None:
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
p = Prefix(organization_id=parent.organization_id, vrf_id=parent.vrf_id, prefix=found, parent_id=parent.id,
description=body.description, status=body.status, is_pool=body.is_pool, note=body.note)
db.add(p)
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
attach_to_tree(db, p, keep_parent=True)
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix)})
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
return _prefix_outs(db, [p])[0]
@router.patch("/prefixes/{id}", response_model=s.PrefixOut)
def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
@@ -204,8 +244,9 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
@router.delete("/prefixes/{id}", status_code=204)
def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
if not force and count(db, select(Address.id).where(Address.prefix_id == id)):
raise HTTPException(409, "В префиксе есть адреса; удалите их или используйте force=true")
used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address))
if used:
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
audit(db, user, "prefix", p, "deleted", str(p.prefix))
db.delete(p)
+18 -13
View File
@@ -1,6 +1,6 @@
"""Справочники: организации, VRF, операторы, типы устройств, устройства."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy import String, cast, delete, func, or_, select
from sqlalchemy.orm import Session
from app import schemas as s
@@ -9,7 +9,7 @@ from app.models import (
Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf,
)
from app.security import admin_user, current_user
from app.services import apply_update, audit, commit, count, flush, get_or_404
from app.services import apply_update, audit, blockers, commit, count, flush, get_or_404, refuse_delete
from fastapi import HTTPException
router = APIRouter(dependencies=[Depends(current_user)])
@@ -67,12 +67,15 @@ def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User
@router.delete("/organizations/{id}", status_code=204, tags=["organizations"])
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
o = get_or_404(db, Organization, id, "Организация")
busy = count(db, select(Prefix.id).where(Prefix.organization_id == id)) or count(
db, select(Device.id).where(Device.organization_id == id)) or count(db, select(Isp.id).where(Isp.organization_id == id))
if busy:
raise HTTPException(409, "Нельзя удалить: у организации есть префиксы, устройства или операторы")
for v in db.scalars(select(Vrf).where(Vrf.organization_id == id)):
db.delete(v)
found = {
"prefixes": blockers(db, select(func.concat(cast(Prefix.prefix, String), " (", Vrf.name, ")")).select_from(Prefix).join(Vrf, Vrf.id == Prefix.vrf_id)
.where(Prefix.organization_id == id).order_by(Prefix.id)),
"devices": blockers(db, select(Device.name).where(Device.organization_id == id).order_by(Device.id)),
"isps": blockers(db, select(Isp.name).where(Isp.organization_id == id).order_by(Isp.id)),
}
if any(found.values()):
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть префиксы, устройства или операторы", found)
db.execute(delete(Vrf).where(Vrf.organization_id == id)) # немедленно: между Vrf и Organization нет relationship(), порядок DELETE в UoW не гарантирован
audit(db, user, "organization", o, "deleted", o.name)
db.delete(o)
commit(db)
@@ -117,8 +120,9 @@ def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user:
@router.delete("/vrfs/{id}", status_code=204, tags=["vrf"])
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
v = get_or_404(db, Vrf, id, "VRF")
if count(db, select(Prefix.id).where(Prefix.vrf_id == id)):
raise HTTPException(409, "Нельзя удалить: VRF используется префиксами")
used = blockers(db, select(cast(Prefix.prefix, String)).where(Prefix.vrf_id == id).order_by(Prefix.id))
if used:
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used})
audit(db, user, "vrf", v, "deleted", v.name)
db.delete(v)
commit(db)
@@ -160,9 +164,10 @@ def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), us
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
t = get_or_404(db, DeviceType, id, "Тип")
if t.is_default:
raise HTTPException(409, "Нельзя удалить тип по умолчанию")
if count(db, select(Device.id).where(Device.device_type_id == id)):
raise HTTPException(409, "Нельзя удалить: тип используется устройствами")
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить тип по умолчанию")
used = blockers(db, select(Device.name).where(Device.device_type_id == id).order_by(Device.id))
if used:
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить: тип используется устройствами", {"devices": used})
audit(db, user, "device_type", t, "deleted", t.name)
db.delete(t)
commit(db)
+93
View File
@@ -0,0 +1,93 @@
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import schemas as s
from app.db import get_db
from app.models import Role, User
from app.security import admin_user, current_user, hash_password, verify_password
from app.services import apply_update, audit, commit, count, flush, get_or_404, refuse_delete
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
def _other_active_admins(db: Session, user_id: int) -> int:
"""Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи."""
return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id))
@router.get("/users", response_model=s.Page[s.UserOut])
def list_users(
q: str = "", limit: int = Query(100, le=500), offset: int = 0,
db: Session = Depends(get_db), admin: User = Depends(admin_user),
):
stmt = select(User)
if q.strip():
stmt = stmt.where(User.username.ilike(f"%{q.strip()}%"))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all()
return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total)
@router.post("/users", response_model=s.UserOut, status_code=201)
def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
raise HTTPException(409, "Пользователь с таким логином уже существует")
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active)
db.add(u)
flush(db, "Пользователь с таким логином уже существует")
audit(db, admin, "user", u, "created", u.username)
commit(db, "Пользователь с таким логином уже существует")
return u
@router.patch("/users/{id}", response_model=s.UserOut)
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
u = get_or_404(db, User, id, "Пользователь")
data = body.model_dump(exclude_unset=True, exclude_none=True)
pwd = data.pop("password", None)
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active)
if u.id == admin.id and (role != Role.admin or not is_active):
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
if loses_admin and _other_active_admins(db, u.id) == 0:
raise HTTPException(409, "Нельзя отключить или понизить единственного активного администратора")
changed = apply_update(u, data)
if pwd:
u.password_hash = hash_password(pwd)
if changed:
audit(db, admin, "user", u, "updated", u.username, changed)
if pwd:
audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
commit(db)
return u
@router.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
u = get_or_404(db, User, id, "Пользователь")
if u.id == admin.id:
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись")
if u.role == Role.admin and u.is_active and _other_active_admins(db, u.id) == 0:
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить единственного активного администратора")
audit(db, admin, "user", u, "deleted", u.username)
db.delete(u)
commit(db)
@router.post("/users/me/password")
def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Смена своего пароля: доступна любой роли, требует текущий пароль."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(403, "Неверный текущий пароль")
user.password_hash = hash_password(body.new_password)
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
commit(db)
return {"ok": True}
+2 -2
View File
@@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.v1 import auth, journal, overview, prefixes, refs
from app.api.v1 import auth, journal, overview, prefixes, refs, users
from app.config import settings
from app.db import SessionLocal
from app.models import DeviceType, Role, User
@@ -41,7 +41,7 @@ app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
app.add_middleware(RequestContextMiddleware)
api = APIRouter(prefix="/api/v1")
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router):
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router):
api.include_router(r)
app.include_router(api)
+59 -2
View File
@@ -5,7 +5,7 @@ from typing import Annotated, Generic, TypeVar
from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.models import AddressStatus, PrefixStatus
from app.models import AddressStatus, PrefixStatus, Role
T = TypeVar("T")
@@ -50,9 +50,52 @@ class TokenOut(BaseModel):
token_type: str = "bearer"
LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$")
RESERVED_LOGINS = {"system", "anonymous"} # заняты служебными акторами журнала (actor_of в app/services.py)
def _login(v: str) -> str:
v = v.strip()
if not LOGIN_RE.match(v):
raise ValueError("Логин: 3–100 символов — латиница, цифры, точка, дефис или подчёркивание")
if v.lower() in RESERVED_LOGINS:
raise ValueError("Логин зарезервирован системой")
return v
Login = Annotated[str, AfterValidator(_login)]
class UserOut(ORM):
id: int
username: str
role: str
role: Role
is_active: bool = True
class UserIn(BaseModel):
username: Login
password: str = Field(min_length=8, max_length=128)
role: Role = Role.admin
is_active: bool = True
class UserUpdate(BaseModel):
role: Role | None = None
is_active: bool | None = None
password: str | None = Field(None, min_length=8, max_length=128)
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
@field_validator("new_password")
@classmethod
def _differs(cls, v: str, info) -> str:
if v == info.data.get("current_password"):
raise ValueError("Новый пароль совпадает с текущим")
return v
# --- organizations
@@ -188,6 +231,20 @@ class PrefixIn(BaseModel):
note: str = ""
class SubnetNextIn(BaseModel):
length: int = Field(ge=1, le=128)
description: str = Field("", max_length=500)
status: PrefixStatus = PrefixStatus.active
is_pool: bool = False
note: str = ""
class SubnetPreview(BaseModel):
prefix: str | None
length_min: int
length_max: int
class PrefixUpdate(BaseModel):
vrf_id: int | None = None
description: str | None = Field(None, max_length=500)
+2 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import VerificationError
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
@@ -23,7 +23,7 @@ def hash_password(password: str) -> str:
def verify_password(password: str, hashed: str) -> bool:
try:
return _ph.verify(hashed, password)
except VerifyMismatchError:
except VerificationError: # несовпадение пароля или повреждённый хэш — не 500, а 401/403
return False
+40
View File
@@ -17,6 +17,7 @@ ANONYMOUS = SimpleNamespace(username="anonymous")
_NOUNS = {
"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"),
"user": ("Пользователь", "m"),
}
_VERBS = {
"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
@@ -48,6 +49,27 @@ def audit(db: Session, user, entity_type: str, entity, action: str, label: str,
))
BLOCKERS_LIMIT = 20
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"}
def blockers(db: Session, labels) -> dict | None:
"""Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто."""
total = count(db, labels)
return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None):
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`."""
blocked_by = {k: v for k, v in (blocked_by or {}).items() if v}
diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})}
noun = _NOUNS.get(entity_type, (entity_type, "m"))[0]
tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:]
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}")
commit(db) # к этому моменту в транзакции только запись журнала
raise HTTPException(409, reason)
def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
try:
db.commit()
@@ -97,6 +119,24 @@ def count(db: Session, stmt) -> int:
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока.
`occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения."""
net = ipaddress.ip_network(parent)
size = 1 << (net.max_prefixlen - length)
first, last = int(net.network_address), int(net.broadcast_address)
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start >= cand + size:
break # блок cand..cand+size-1 свободен
cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием
if cand + size - 1 > last:
return None
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
def next_free(db: Session, prefix_id: int, prefix: str) -> str | None:
net = ipaddress.ip_network(prefix)
used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))}
+52
View File
@@ -0,0 +1,52 @@
# Раздел интерфейса «Пользователи» (изменение 006)
## Context
Роли `admin` (запись) и `viewer` (чтение) заявлены в README и в UI (журнал: настройки и очистка скрыты для не-админа),
но назначить роль негде: таблица `users` заполняется только записью администратора при первом старте (`seed()` в `app/main.py`),
эндпоинтов управления пользователями нет, сменить пароль — тоже нельзя. Нужен раздел интерфейса, где администратор
заводит учётные записи, меняет им роль/пароль и отключает доступ, а любой пользователь может сменить свой пароль.
## Модель (без изменения схемы БД)
Таблица `users` (миграция 0001) уже содержит всё необходимое: `id`, `username` (уникальный), `password_hash` (argon2),
`role` (`user_role`: admin/viewer), `is_active`. Миграция не требуется. Пароли не хранятся и не пишутся в журнал в открытом виде.
## API (`/api/v1/users`, все — под авторизацией)
| Метод | Кто | Назначение |
|---|---|---|
| `GET /users` | admin | список: логин (поиск `q`), роль, признак активности, `limit`/`offset` |
| `POST /users` | admin | создание: `username`, `password` (≥ 8), `role`, `is_active` |
| `PATCH /users/{id}` | admin | роль, `is_active`, необязательный новый `password` |
| `DELETE /users/{id}` | admin | удаление учётной записи |
| `POST /users/me/password` | любой | смена **своего** пароля с подтверждением текущего |
Правила (иначе 409/422, состояние не меняется):
- логин: 3–100 символов, `[A-Za-z0-9._-]`, уникален; зарезервированные `system` и `anonymous` запрещены —
журнал различает служебные события по этим именам (`actor_of` в `app/services.py`);
- нельзя удалить или отключить/понизить **свою** учётную запись (иначе текущая сессия теряет доступ);
- нельзя отключить, понизить или удалить **единственного активного администратора** (система осталась бы без прав записи);
- неверный текущий пароль при смене своего пароля → 403.
Аудит: `user.created`, `user.updated`, `user.password_reset` (пароль администратором и своя смена), `user.deleted`;
в `entity_label` — логин, в `diff` — только изменённые поля (`password` отмечен как «изменён», без значения).
Сущность `user` добавлена в словарь человекочитаемых сообщений (`_NOUNS` в `app/services.py`).
## UI (`web/app.js`)
- Пункт навигации «Пользователи» — только для администратора (`NAV` фильтруется в `shell()`); при ручном вводе `#/users`
не-админ видит заглушку «Раздел доступен только администратору».
- Экран: счётчик записей, поиск по логину, таблица «Логин · Роль · Статус · Действия»; строка кликабельна (паттерн изменения 005),
в меню «⋯» — «Редактировать», «Отключить/Активировать», «Удалить».
- Окно создания/редактирования: логин (при создании), роль, флажок «Доступ разрешён», необязательное поле «Новый пароль»
(пустое — пароль не меняется); окно смены своего пароля — в шапке («Сменить пароль»).
- В журнале: подпись сущности «Пользователь», тип события в фильтре типов появляется автоматически (`/audit/facets`).
## Ограничения (осознанные)
- JWT без списка отзыва: после **смены пароля** уже выданные токены продолжают работать до истечения `JWT_TTL_MINUTES`;
**отключение** учётной записи действует немедленно (токен проверяется по `users.is_active` в `current_user`).
- Логин менять нельзя (он же `sub` в токене) — вместо этого создаётся новая учётная запись.
- Нет саморегистрации, восстановления пароля по e-mail, групп прав и журналирования последнего входа.
## Проверка
- Интеграционный тест `tests/test_users.py` (один сценарий, против контейнеров): создание, дубль логина → 409,
запрет служебного логина → 422, вход новым пользователем, запрет записи для `viewer` → 403,
отключение → вход 401, сброс пароля → вход с новым паролем, смена своего пароля и вход с ним, удаление своей записи → 409.
- Синтаксическая проверка Python и JS; ручной сценарий в браузере (создание, редактирование, отключение, вход отключённым, удаление).
@@ -0,0 +1,49 @@
# Итог: раздел интерфейса «Пользователи» (изменение 006)
## Что сделано
- **API `/api/v1/users`** (`app/api/v1/users.py`, роутер подключён в `app/main.py`):
`GET /users` (поиск по логину, `limit`/`offset`), `POST /users`, `PATCH /users/{id}`, `DELETE /users/{id}` — только `admin`;
`POST /users/me/password` — смена своего пароля любой ролью с подтверждением текущего.
- **Схемы** (`app/schemas.py`): `UserIn`, `UserUpdate`, `PasswordChange`, `UserOut` (`id`, `username`, `role`, `is_active`);
валидатор логина (`Login`, 3–100 символов, `[A-Za-z0-9._-]`) и запрет служебных логинов `system`/`anonymous`.
- **Журнал**: сущность `user` добавлена в словарь сообщений (`app/services.py`), события `user.created`, `user.updated`,
`user.password_reset` («пароль изменён администратором» / «пароль изменён пользователем»), `user.deleted`; в `diff` — только
изменённые поля, для пароля — пометка «изменён» без значения. В UI — подпись «Пользователь» и цвет бейджа для `password_reset`.
- **UI** (`web/app.js`): пункт навигации «Пользователи» (только у администратора), экран со счётчиком, поиском и таблицей
«Логин · Роль · Статус» (строка кликабельна, меню «⋯»: редактировать, отключить/разрешить доступ, удалить), окно создания
и редактирования (логин после создания не редактируется, необязательный «Новый пароль», флажок «Доступ разрешён»),
кнопка «Сменить пароль» в шапке, заглушка для не-администратора при ручном вводе `#/users`.
- **Тест** `tests/test_users.py` — один сквозной сценарий.
- **Документация**: обновлён `README.md`, план и итог в `docs/changes/006-users-management/`.
## Поведение
| Ситуация | Ответ |
|---|---|
| Логин занят (в т. ч. в другом регистре) | 409 «Пользователь с таким логином уже существует» |
| Логин `system`/`anonymous`, короче 3 символов, пароль короче 8 | 422 с указанием поля |
| `viewer` читает `/users` или что-либо изменяет | 403 |
| `PATCH`/`DELETE` своей учётной записи (роль, доступ, удаление) | 409 |
| Отключение или удаление последнего активного администратора | 409 |
| Неверный текущий пароль при смене своего пароля | 403 |
| Новый пароль совпадает с текущим | 422 |
## Действие ролей
- `admin` — полный доступ, включая управление пользователями и запись данных.
- `viewer` — чтение всех справочников, реестра, журнала и обзора; запись (403) и раздел «Пользователи» недоступны.
- **Отключение** учётной записи действует немедленно: токен проверяется по `users.is_active` в `current_user`.
- **Смена пароля** не отзывает уже выданные токены (JWT без списка отзыва) — они действуют до истечения `JWT_TTL_MINUTES`.
## Инварианты
- Схема БД не менялась: таблица `users` (миграция 0001) уже содержит `username`, `password_hash`, `role`, `is_active`.
- Пароли хранятся только в виде argon2-хэша; значение пароля никогда не попадает в журнал.
- Логин не меняется: он же `sub` в JWT; переименование = создание новой записи.
- Логин уникален, в том числе без учёта регистра; служебные акторы журнала зарезервированы.
- Перед каждым изменением проверяется, что в системе останется хотя бы один активный администратор.
## Проверка
- Python: `python3 -m py_compile` по изменённым модулям — без ошибок; автоматическая проверка после правок — успешно.
- Интеграционный тест: `docker compose up -d --build && venv/bin/python -m pytest tests/test_users.py -q`
(создание viewer, запреты 403/409/422, вход, отключение с немедленным отзывом токена, сброс пароля администратором,
смена своего пароля, защита своей записи и последнего администратора, удаление, записи в журнале).
- Ручной сценарий в UI: вход администратором → «Пользователи» → создание viewer → вход в другом окне → отключение
(вход и текущая сессия отбиваются) → сброс пароля → вход с новым паролем → «Сменить пароль» в шапке.
+29
View File
@@ -0,0 +1,29 @@
# Исправление: удаление организации возвращает 409 (изменение 007)
## Context
`DELETE /api/v1/organizations/5` отвечает `409 «Запись с такими значениями уже существует»`, хотя у организации нет префиксов, устройств и операторов
(проверено по БД: у org 5 только служебный VRF `default`). Так же падает удаление организации 4.
## Причина (подтверждена логом PostgreSQL)
`ERROR: update or delete on table "organizations" violates foreign key constraint "vrfs_organization_id_fkey" ... Key (id)=(5) is still referenced from table "vrfs"`.
В `delete_org` (`app/api/v1/refs.py:67-79`) VRF удаляются через `db.delete(v)`, затем `db.delete(o)`, всё одним `commit`. Между `Vrf` и `Organization` нет ORM-`relationship()`,
поэтому unit of work не гарантирует порядок DELETE, и `DELETE FROM organizations` уходит раньше `DELETE FROM vrfs`. `IntegrityError` в `commit()` (`app/services.py:52`)
превращается в общий 409 с вводящим в заблуждение текстом.
## Исправление
1. `app/api/v1/refs.py`, `delete_org`: заменить цикл `for v in ...: db.delete(v)` на `db.execute(delete(Vrf).where(Vrf.organization_id == id))`
(добавить `delete` в импорт `sqlalchemy`) — выполняется немедленно, до `db.delete(o)`. Аудит и проверка «занято» без изменений.
2. Схема БД, миграции и UI не меняются.
3. Остальные `db.delete` в проекте (`vrfs`, `device-types`, `devices`, `isps`, `prefixes`, `addresses`, `users`) удаляют одиночную запись без зависимых строк, вне области правки.
## Артефакты (правила проекта)
- `docs/changes/007-org-delete-fix/PLAN.md` (этот план) и `SUMMARY.md` — причина, правка, проверка.
- `README.md`: короткая пометка в разделе об удалении организаций/поведении API (если такой раздел есть), иначе строка в списке изменений.
## Тест (минимум)
Один тест в `tests/test_api.py`: создать организацию → `DELETE` → 204 → `GET` → 404; организация с префиксом → `DELETE` → 409 с текстом «Нельзя удалить…».
## Проверка
1. `docker compose -p ipam_control_006 up -d --build app` (пересборка только приложения; БД и данные сохраняются, прежняя поставка `ipam_control-*` не затрагивается).
2. `venv/bin/python -m pytest -q tests/test_api.py` — против запущенного стенда (BASE из `tests/conftest.py`).
3. Вручную в UI (http://192.168.5.9:8088): удалить организации «ООО «Дата-Центр»» (id 5) и «ООО «СтройМонтаж»» (id 4) — 204; попытка удалить «ООО «Технологии связи»» (есть префиксы) — 409 «Нельзя удалить…»; записи `organization.deleted` появились в журнале.
@@ -0,0 +1,19 @@
# Итог: исправление удаления организации (изменение 007)
## Проблема
`DELETE /api/v1/organizations/{id}` возвращал `409 «Запись с такими значениями уже существует»` для организаций без префиксов, устройств и операторов.
## Причина
В `delete_org` служебные VRF и сама организация удалялись одним `commit()` через `db.delete()`. Между `Vrf` и `Organization` нет ORM-`relationship()`,
поэтому порядок DELETE в unit of work не гарантирован: `DELETE FROM organizations` выполнялся раньше `DELETE FROM vrfs` и нарушал FK `vrfs_organization_id_fkey`.
Ошибка `IntegrityError` в `commit()` превращалась в общий 409. Тесты проблему не видели: фикстура `org` перед удалением организации вручную удаляет её VRF.
## Что сделано
- `app/api/v1/refs.py`: VRF организации удаляются явным `db.execute(delete(Vrf).where(...))` до удаления организации.
- `tests/test_api.py`: `test_delete_organization` — организация с префиксом → 409 «Нельзя удалить…»; пустая организация (VRF `default` удаляется вместе с ней) → 204, затем 404.
- Схема БД, миграции и UI не менялись.
## Проверка
- Новый тест на старом коде падал (409), на новом проходит; полный набор — 13 passed.
- Один из прогонов сразу после перезапуска контейнера дал разовый сбой `test_journal_search_and_filters` (`total == 0`); два повторных полных прогона и отдельный прогон — без ошибок. Связь с правкой не выявлена.
- Стенд `ipam_control_006` пересобран только по сервису `app`, БД и данные сохранены.
@@ -0,0 +1,48 @@
# Журнал: события отклонённого удаления (изменение 008)
## Context
Когда удаление запрещено бизнес-правилом (например, у организации есть префиксы/устройства/операторы), API отвечает 409 через `raise HTTPException`,
а запись в журнал не пишется: `audit()` вызывается только на успешном пути, сессия закрывается без commit (`app/db.py:get_db`).
Нужно фиксировать такие попытки как предупреждения и показывать в блоке «Данные» окна записи журнала, какие именно связанные сущности мешают удалению.
## Решения
- **Тип события:** `<entity>.delete_blocked` (например `organization.delete_blocked`) — вписывается в существующую схему `entity_type` + `action`,
фильтры и facets журнала подхватывают его без изменений (`app/api/v1/journal.py` разбирает `event_type` по точке).
- **Охват:** все отказы в удалении по бизнес-правилам (409): организация, VRF, тип устройства (в т.ч. «по умолчанию»), префикс с адресами,
пользователь (свой аккаунт / последний администратор). Прочие 409/422 (дубли, валидация) не журналируются.
- **Содержимое «Данных»** — уже существующее поле `audit_log.diff` (JSONB), миграция БД не нужна:
```json
{"reason": "у организации есть префиксы, устройства или операторы",
"blocked_by": {"prefixes": {"total": 13, "items": ["10.0.0.0/8 (default)", "…"]},
"devices": {"total": 2, "items": ["db-master.internal", "…"]},
"isps": {"total": 1, "items": ["Ростелеком"]}}}
```
В списке не более 20 элементов на группу, `total` — полное число. Пустые группы не выводятся. Для отказов без связанных объектов
(свой аккаунт, тип по умолчанию, последний админ) — только `reason`.
- **Сообщение:** «Организация ООО «X»: удаление отклонено — есть связанные объекты (префиксы: 13, устройства: 2, операторы: 1)».
## Реализация
1. `app/services.py`: хелпер `refuse_delete(db, user, entity_type, entity, label, reason, blocked_by=None)` — пишет `audit(..., "delete_blocked", label, diff, message=...)`,
делает `commit(db)` (в транзакции только запись журнала, других изменений к этому моменту нет) и поднимает `HTTPException(409, reason_text)`.
Текст ответа API остаётся прежним. Хелпер `_blockers(db, stmt, label_col, limit=20)` — `{"total", "items"}` по запросу (переиспользуем `count()`).
2. Вызовы вместо прямых `raise HTTPException(409, …)` в:
- `app/api/v1/refs.py`: `delete_org` (префиксы: `prefix (vrf)`, устройства: `name`, операторы: `name`), `delete_vrf` (префиксы), `delete_type` (устройства + случай `is_default`);
- `app/api/v1/prefixes.py`: `delete_prefix` (адреса, до 20 шт.);
- `app/api/v1/users.py`: `delete_user` (два отказа: свой аккаунт, единственный активный админ).
Для организации проверка «занято» переписывается с `or`-цепочки на три подсчёта, чтобы собрать все группы сразу.
3. `web/app.js`: `eventBadge` — цвет `amber` для `delete_blocked`; в `actionBadge` обзора — `delete_blocked: ["amber", "отклонено"]`.
Блок «Данные» (`entryDialog`) уже выводит `diff` как JSON — отдельная вёрстка не нужна.
4. `_VERBS` в `make_message` не трогаем: сообщение передаётся явно.
## Артефакты (правила проекта)
`docs/changes/008-blocked-delete-audit/PLAN.md` (копия этого плана) и `SUMMARY.md`; `README.md` — в раздел «Журнал»: событие `*.delete_blocked`, состав «Данных».
## Тест (минимум)
Расширить `test_delete_organization` в `tests/test_api.py`: после 409 на организации с префиксом найти в `/audit?event_type=organization.delete_blocked&q=<имя>`
запись, проверить `diff.blocked_by.prefixes.total == 1` и наличие CIDR в `items`.
## Проверка
1. `docker compose -p ipam_control_006 up -d --build app` (БД и прежняя поставка не затрагиваются); `venv/bin/python -m pytest -q`.
2. В UI (http://192.168.5.9:8088): «Организации» → «Удалить» у «ООО «Технологии связи»» → 409; в «Журнале» появилась запись `organization.delete_blocked`
(жёлтый бейдж), в окне записи блок «Данные» показывает списки префиксов/устройств/операторов; фильтр по типу события находит её.
3. Отказ на удаление VRF с префиксами и своего аккаунта — аналогичные записи.
@@ -0,0 +1,23 @@
# Итог: журнал фиксирует отклонённые удаления (изменение 008)
## Что сделано
- **Событие `<entity>.delete_blocked`** (`organization`, `vrf`, `device_type`, `prefix`, `user`) пишется при каждом отказе в удалении по бизнес-правилу (409).
Ответ API не менялся: тот же код и текст. Фильтры и список типов событий журнала подхватили новый тип без правок.
- **`app/services.py`**: `refuse_delete()` (запись в журнал + commit + 409) и `blockers()` (общее число и до 20 названий мешающих объектов).
- **Вызовы**: `delete_org` (префиксы `CIDR (VRF)`, устройства, операторы — все группы сразу), `delete_vrf` (префиксы), `delete_type` (устройства; тип по умолчанию),
`delete_prefix` (адреса, кроме `force=true`), `delete_user` (свой аккаунт, единственный активный администратор).
- **«Данные» записи** (`audit_log.diff`, схема БД не менялась):
`{"reason": "…", "blocked_by": {"prefixes": {"total": 13, "items": ["10.0.0.0/8 (default)", …]}, "devices": {…}, "isps": {…}}}`;
для отказов без связанных объектов — только `reason`. Окно записи уже выводит `diff` как JSON.
- **UI** (`web/app.js`): жёлтый бейдж для `delete_blocked` в журнале и подпись «отклонено» в последних изменениях на «Обзоре».
- **Тест**: `test_delete_organization` расширен — запись `organization.delete_blocked` с `blocked_by.prefixes`.
## Проверка
- Полный набор тестов: 13 passed. Вручную отказы на организацию, VRF, свой аккаунт и тип по умолчанию дали записи с ожидаемым содержимым.
- Первая версия падала 500 (JOIN без левой стороны в запросе списка префиксов организации) — поймано тестом, исправлено `select_from(Prefix)`.
- При ручной проверке демо-префикс `10.0.0.0/8` был удалён ошибочно (у него нет собственных адресов, отказа не было) и восстановлен через API: дерево прежнее, но id стал 67.
- Стенд `ipam_control_006` пересобран только по сервису `app`.
## Ограничения
- Журналируются только отказы удаления; прочие 409 (дубли, «нет свободных адресов», отказ понизить свой аккаунт при `PATCH`) — нет.
- Каждая повторная попытка даёт новую запись (дедупликации нет).
+53
View File
@@ -0,0 +1,53 @@
# Множественный выбор и групповые операции в UI (изменение 009)
## Context
Сейчас каждую запись можно удалить или изменить только по одной через меню «⋯». Нужен выбор нескольких строк чекбоксами и групповые операции
над выбранными на всех экранах управления, кроме «Журнала». Решения пользователя: операции — удаление везде, плюс доступ пользователей,
смена типа устройств, статусы префиксов и адресов; выполнение — цикл запросов из UI (бэкенд, API, схема БД и миграции **не меняются**).
## Охват
| Экран | Чекбокс у строк | Групповые операции |
|---|---|---|
| Организации | все | Удалить |
| Операторы | все | Удалить |
| Устройства | все | Удалить; «Сменить тип ▾» |
| Префиксы | только листовые (как и меню «⋯» сейчас) | Удалить; «Статус ▾» (Активен/Резерв/Устарел) |
| Адреса подсети | только занятые/резерв/устаревшие (у «Свободен» нет id) | Удалить; «Статус ▾» (Назначен/Резерв/Устаревший) |
| Пользователи | все, кроме своей учётной записи (чекбокс disabled) | Удалить; «Разрешить доступ»; «Отключить доступ» |
| Журнал | — не затрагивается | — |
Не входят: окна «Управление VRF» и «Управление типами» (это модальные мини-списки, а не вкладки). Для роли `viewer` чекбоксов нет (запись запрещена).
## Реализация (`web/app.js`, `web/styles.css`)
- **Состояние:** `S.sel` (Set id) и `S.selectable` (id выбираемых строк текущего экрана). `S` сбрасывается при смене экрана; при каждой перерисовке
выделение усекается до присутствующих строк (`pruneSel`), поэтому поиск/фильтры не оставляют «невидимых» выбранных.
- **Общие фрагменты** рядом с `badge/btn/iconBtn`: `selCell(id, disabled)` (чекбокс в `<label>`, `id="sel-<id>"` — фокус восстанавливается штатным кодом `draw()`),
`selAllCell()` (в шапке; `indeterminate` выставляется после рендера в `draw()`), `selCols(cols)` (добавляет колонку `40px`; для не-админа — без неё),
`bulkBar({...})` (панель «Выбрано: N · действия · Снять выделение», рендерится только при `S.sel.size`), `bulkMenu(id, label, items)` (на существующих `popMenu`/`data-action="menu"`).
- **Экраны:** в `screens.orgs/isps/devices/prefixes/address/users` — `selCols` в `--cols` строк и шапки (у орг. выносится в константу), `selCell` первой ячейкой, панель `bulkBar` под фильтрами,
класс `selected` у выбранных строк. Клики по чекбоксу не открывают строку: общий обработчик уже пропускает `input`/`label` (изменение 005).
- **Исполнитель** `bulk(title, ids, labelOf, call, after)`: последовательно вызывает существующие `DELETE`/`PATCH` (`/organizations`, `/isps`, `/devices`, `/prefixes`, `/addresses`, `/users`),
ловит `ApiError` по каждому элементу, останавливается на 401, защищён от повторного запуска (`S.busy`). По итогу: toast «выполнено N из M», перерисовка,
успешные снимаются с выделения (отказавшие остаются выбранными), при отказах — окно «Не выполнено для: …» со списком «объект — причина» (`openDialog`).
Для организаций после цикла вызывается `loadOrgs()`.
- **Подтверждение** удаления — `confirm()`, как в одиночных действиях («Удалить выбранное: 3 оператора?»). Префиксы удаляются без `force`: с адресами будут отклонены
и попадут в список отказов (принудительное удаление остаётся в одиночном меню).
- **Диспетчеризация:** новые `actions`: `sel`, `sel-all`, `sel-clear`, `bulk-del`, `bulk-access`; в `actions.pick` ветка `bulk-status` / `bulk-type` до общего `rowActions`-поиска.
Экран определяется через `route()` (`prefixes` с аргументом = «адреса»).
- **CSS:** `.sel-cell` (центрирование, чекбокс 16 px, `accent-color: var(--primary)`), `.bulkbar` (строка 48 px, фон `#f3f7ff`, `.pop` от `top:40px`), `.tr.selected{background:#eef3ff}`.
## Журнал и бэкенд
Каждое отклонённое удаление в цикле само попадает в журнал как `<entity>.delete_blocked` (изменение 008); каждое успешное действие — обычной записью по объекту.
Групповая операция отдельным событием не пишется. Серверные правила сохраняются (нельзя удалить/понизить себя и последнего админа → строка в списке отказов).
## Артефакты (правила проекта)
`docs/changes/009-bulk-actions/PLAN.md` (копия плана) и `SUMMARY.md`; `README.md` — в раздел «Поведение таблиц UI»: выбор чекбоксами, панель групповых действий, охват и ограничения.
Автотесты: бэкенд не менялся, новых тестов нет (правило «минимум тестов»); существующие 13 должны проходить.
## Проверка
1. `node --check web/app.js`; `docker compose -p ipam_control_006 up -d --build app`; `venv/bin/python -m pytest -q`.
2. Интерфейс (http://192.168.5.9:8088), по возможности через браузерную автоматизацию (skill `claude-in-chrome`), иначе вручную:
- Операторы: выбрать 2–3 → «Удалить» → подтверждение → строки исчезли, toast «выполнено 3 из 3»; «Выбрать все» в шапке и состояние «частично» работают.
- Организации: выбрать организацию с префиксами и пустую → удалено 1 из 2, окно отказов с причиной; в «Журнале» появилась `organization.delete_blocked`.
- Устройства: «Сменить тип»; Префиксы/Адреса: «Статус»; Пользователи: «Отключить/Разрешить доступ», у своей строки чекбокс недоступен.
- Для `viewer` чекбоксов и панели нет; на «Журнале» — нет.
3. Тестовые данные для проверки создавать отдельными демо-записями (не удалять существующие демо-данные стенда).
+28
View File
@@ -0,0 +1,28 @@
# Итог: выбор нескольких объектов и групповые операции (изменение 009)
## Что сделано
- **Чекбоксы и панель действий** на экранах «Организации», «Операторы», «Устройства», «Префиксы», «Адреса подсети», «Пользователи» (`web/app.js`, `web/styles.css`).
Чекбокс в шапке — «выбрать все» (состояние «частично» при неполном выборе). Выбранные строки подсвечены; над таблицей — панель «Выбрано: N · действия · Снять выделение».
«Журнал» не затронут; окна «Управление VRF/типами» не входят. Роль `viewer` чекбоксов и панели не видит.
- **Операции:** удаление на всех экранах; «Сменить тип» (устройства); «Статус» (префиксы: Активен/Резерв/Устарел; адреса: Назначен/Резерв/Устаревший);
«Разрешить/Отключить доступ» (пользователи).
- **Что выбирается:** префиксы — только листовые (как и меню «⋯»); адреса — только не «Свободен»; пользователи — все, кроме своей записи (чекбокс недоступен).
Выделение усекается до видимых строк: поиск, фильтр, свёрнутая ветка префиксов и смена экрана не оставляют «невидимых» выбранных.
- **Выполнение** — последовательные вызовы существующих `DELETE`/`PATCH` из UI (`bulk()`); бэкенд, API, БД и миграции не менялись.
Итог — toast «Удаление: выполнено N из M». Отказ по объекту не прерывает остальные: причины показаны в окне «Выполнено не для всех объектов»,
отказавшие остаются выбранными, выполненные — снимаются с выделения. Серверные правила сохраняются (свой аккаунт, последний администратор, зависимые объекты).
Каждый отказ удаления сам попадает в журнал как `<сущность>.delete_blocked` (изменение 008). Групповая операция отдельным событием не пишется.
- **Удаление префиксов** — без `force`: префикс с адресами отклоняется и попадает в список отказов; принудительное удаление остаётся в одиночном меню.
- **Артефакты:** этот итог, план, раздел «Поведение таблиц UI» в `README.md`.
## Проверка
- `node --check web/app.js`; стенд `ipam_control_006` пересобран (только `app`); backend-тесты — 13 passed (новых нет: бэкенд не менялся).
- Сквозная проверка в headless Chromium (Playwright) на временных данных, 25 проверок, все пройдены: удаление 3 операторов; удаление пары организаций (1 удалена, 1 отклонена
с окном причин и записью `organization.delete_blocked`); смена типа и удаление устройств; отключение доступа и удаление пользователей (у своей записи чекбокс недоступен);
смена статуса префиксов и отказ удаления префикса с адресами; смена статуса и «выбрать все» + удаление адресов; в «Журнале» и для `viewer` чекбоксов нет.
Временные данные удалены. Скрипт проверки в репозиторий не добавлялся (правило «минимум тестов»).
## Ограничения
- Операции выполняются по одному запросу на объект, без общей транзакции: при обрыве связи часть объектов может остаться необработанной (видно по итоговому сообщению).
- «Выбрать все» действует на загруженные строки (на экране адресов — на уже показанные, «Показать ещё 100» подгружает следующие).
- Свёртывание ветки префиксов снимает выделение со скрытых дочерних строк.
+38
View File
@@ -0,0 +1,38 @@
# Автовыделение следующего вложенного префикса (изменение 010)
## Context
Когда префикс-контейнер (например `172.20.0.0/24`) дробят на подсети одного размера (`/30`), свободный блок приходится считать вручную.
Нужна кнопка: пользователь выбирает родителя и размер (`/30`), система сама находит первый свободный выровненный блок внутри родителя и создаёт дочерний префикс.
## Алгоритм выбора блока (`app/services.py`, чистая функция, без БД)
`next_free_subnet(parent_cidr, length, occupied) -> str | None`:
1. `occupied` — диапазоны `[start, end]` (целые) всех **уже существующих префиксов внутри родителя в том же VRF** (все уровни вложенности)
и адресов, записанных на самом родителе (чтобы не выдать блок, где уже есть назначенные IP). Диапазоны сортируются и склеиваются.
2. Кандидат начинается с адреса родителя и выравнивается по размеру блока (`ceil(start / size) * size`); если пересекает занятый диапазон — перескок за его конец, повторное выравнивание.
Сложность O(n) от числа занятых диапазонов, без перебора всех подсетей (работает и для `/8 → /30`, и для IPv6).
3. Кандидат не выходит за границу родителя; иначе `None`. Первый подходящий (наименьший адрес) — предсказуемо: `.0/30`, `.4/30`, `.8/30`…; для смешанных размеров блок выравнивается сам (`/29` после `.0/30`, `.4/30` → `.8/29`).
## API (`app/api/v1/prefixes.py`, схемы в `app/schemas.py`)
- `POST /prefixes/{id}/subnets/next` (admin) — тело `SubnetNextIn`: `length` (int), `description`, `status` (по умолчанию `active`), `is_pool`, `note`. Создаёт префикс в VRF и организации родителя, `parent_id = id`.
Возвращает `PrefixOut` (201). Ошибки: `length` не больше длины родителя или больше 32/128 → 422 (с пределами в тексте); нет свободного блока → 409 «В префиксе нет свободного блока /30».
Родитель блокируется `SELECT … FOR UPDATE` — параллельные запросы не выдают один блок; уникальность `(vrf_id, prefix)` остаётся страховкой (`flush` → 409).
Переиспользуются `get_or_404`, `flush/commit`, `attach_to_tree(keep_parent=True)` (подхват существующих вложенных, если они шире), `audit(... "created", …, {"vrf": …, "allocated_from": "<родитель>"})`, `_prefix_outs`.
- `GET /prefixes/{id}/subnets/next?length=30` — предпросмотр без создания: `{"prefix": "172.20.0.4/30"|null, "length_min": 25, "length_max": 32}` (для окна в UI; те же проверки диапазона).
- БД, миграции, существующие эндпоинты не меняются.
## UI (`web/app.js`)
- Пункт **«Добавить вложенный (авто)»** в меню «⋯» строки префикса: у листовых — рядом с «Открыть адреса/Редактировать/Удалить»; у родителей (сейчас без меню) — меню из этого одного пункта.
Доступно только admin (как остальные меню записи).
- Окно `subnetDialog(parent)` «Новый вложенный префикс в 172.20.0.0/24»: выпадающий «Размер» (`/25 … /32` для v4, для v6 — до `/128`; по умолчанию — длина последнего дочернего префикса, иначе `/30` (v4) / `/64` (v6) в пределах допустимого),
строка «Будет создан: **172.20.0.4/30**» (обновляется по `change` списка через `GET …/subnets/next`; при отсутствии места — предупреждение и заблокированная кнопка), «Описание», «Статус», «Примечание», флажок «Пул». Использует `openDialog`, `formBody`, `fSelect`, `fInput`, `dlgFoot`, `S.dialog`.
- После успеха: toast «Создан префикс 172.20.0.4/30», родитель раскрывается (`S.collapsed.delete(parent.id)`), `draw()`.
## Артефакты и тесты
- `docs/changes/010-next-free-prefix/PLAN.md` (копия) и `SUMMARY.md`; `README.md` — раздел API/Модель данных: алгоритм и эндпоинты.
- Один тест `test_allocate_next_subnet` в `tests/test_api.py`: родитель `/24` + ручной `/30` в начале → `/30` даёт `.4/30`; `/29` даёт `.8/29` (выравнивание); `parent_id` = родитель; предпросмотр совпадает с созданием;
`length` ≤ длины родителя → 422; после заполнения всех блоков `/25` → 409. Плюс мини-проверка чистой функции на IPv6 (в том же тесте).
## Проверка
1. `node --check web/app.js`; `docker compose -p ipam_control_006 up -d --build app`; `venv/bin/python -m pytest -q`.
2. Браузером (Playwright headless, как в 009): создать `172.20.0.0/24` → вручную `172.20.0.0/30` → на родителе «⋯» → «Добавить вложенный (авто)» → в окне «Будет создан: 172.20.0.4/30» → создать 3 раза подряд (`.4`, `.8`, `.12`) → строки в дереве под родителем, родитель раскрыт;
размер `/29` → следующий выровненный блок; в «Журнале» записи `prefix.created`. Тестовые данные удалить.
@@ -0,0 +1,28 @@
# Итог: автовыделение следующего вложенного префикса (изменение 010)
## Что сделано
- **Алгоритм** `next_free_subnet()` (`app/services.py`): первый свободный блок заданного размера внутри родителя, выровненный по границе блока; занятое — все префиксы внутри родителя в том же VRF
(любой вложенности) и адреса, записанные на самом родителе. Перескок за конец занятого диапазона, без перебора подсетей (работает для IPv4 и IPv6).
Примеры: `.0/30` занят → `.4/30`; после `.0/.4/.8` блок `/29` → `.16/29` (выравнивание); `/25` при занятой нижней половине → `.128/25`.
- **API** (`app/api/v1/prefixes.py`, `app/schemas.py`): `POST /prefixes/{id}/subnets/next` (admin; `length`, `description`, `status`, `is_pool`, `note`) создаёт вложенный префикс в VRF и организации родителя, 201;
`GET /prefixes/{id}/subnets/next?length=` — предпросмотр без создания. Размер вне `/родитель+1 … /32|/128` → 422; нет свободного блока → 409 «В префиксе … нет свободного блока /N».
Родитель блокируется `FOR UPDATE` (параллельные запросы не получают один блок); журнал — `prefix.created` с `allocated_from`.
- **UI** (`web/app.js`): пункт «Добавить вложенный (авто)» в меню «⋯» строки префикса (у листовых — вместе с прежними, у родителей — новое меню из одного пункта; только admin).
Окно: «Размер» (по умолчанию — как у последнего дочернего, иначе /30 для IPv4 и /64 для IPv6), живая строка «Будет создан: 100.70.0.4/30», описание, статус, примечание, флажок «Пул».
Если места нет — предупреждение и заблокированная кнопка. После создания родитель раскрывается.
- БД и миграции не менялись. Документация: `README.md`, план и итог.
## Проверка
- `test_allocate_next_subnet` (14 passed всего): предпросмотр = создание, `.4/30`, `.8/30`, выравнивание `/29`, 422 на неверный размер, `/25`, затем 409, проверка IPv6 в чистой функции.
- Сквозная проверка в headless Chromium: родитель + ручной /30 → три вызова из меню подряд дали `.4`, `.8`, `.12`; смена размера на /29 → предпросмотр `.16/29`; пункт есть у листа; запись в журнале с `allocated_from`.
Тестовые данные удалены.
## Ограничения
- Блок всегда первый по возрастанию адреса (без поиска «наилучшего» по фрагментации). Освободившийся раньше блок будет выдан повторно.
- За раз создаётся один префикс; массовое выделение (N подряд) можно добавить отдельно.
- Адреса, привязанные к самому родителю, считаются занятыми: блок с ними не выдаётся.
## Попутно: нестабильные тесты журнала
Тесты `tests/test_journal.py` изредка падали (`assert 0 >= 1`, `IndexError`), в изменениях 007–009 это отмечалось как «разовый сбой без выявленной причины». Причина найдена: тестовый IPv6-префикс собирался из
случайных hex-групп с ведущими нулями (`fd00:0e3a:…`), а PostgreSQL хранит CIDR в нормализованном виде (`fd00:e3a:…`), поэтому поиск по журналу не находил запись (~1 из 8 запусков на тест).
Исправлено в тестах (группы без ведущих нулей); 6 полных прогонов подряд — 14 passed. Код приложения не менялся.
+32
View File
@@ -1,3 +1,4 @@
import ipaddress
import httpx
from tests.conftest import BASE, ENV
@@ -45,6 +46,37 @@ def test_in_use_objects_cannot_be_deleted(client, org):
assert client.delete(f"/device-types/{t['id']}").status_code == 409
def test_delete_organization(client, org):
_prefix(client, org, "10.206.0.0/24")
busy = client.delete(f"/organizations/{org['id']}") # с префиксом — нельзя
assert busy.status_code == 409 and "Нельзя удалить" in busy.json()["message"]
logged = client.get("/audit", params={"event_type": "organization.delete_blocked", "q": org["name"]}).json()["items"] # отказ попадает в журнал
assert logged and logged[0]["entity_id"] == org["id"] and "удаление отклонено" in logged[0]["message"]
assert logged[0]["diff"]["blocked_by"]["prefixes"] == {"total": 1, "items": ["10.206.0.0/24 (default)"]} and "devices" not in logged[0]["diff"]["blocked_by"]
empty = client.post("/organizations", json={"name": f"empty-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
assert client.delete(f"/organizations/{empty['id']}").status_code == 204 # служебный VRF default удаляется вместе с организацией
assert client.get(f"/organizations/{empty['id']}").status_code == 404
assert client.get("/vrfs", params={"organization_id": empty["id"]}).json()["items"] == []
def test_allocate_next_subnet(client, org):
parent = _prefix(client, org, "10.207.0.0/24").json()
assert _prefix(client, org, "10.207.0.0/30").json()["parent_id"] == parent["id"]
url = f"/prefixes/{parent['id']}/subnets/next"
assert client.get(url, params={"length": 30}).json()["prefix"] == "10.207.0.4/30" # предпросмотр
a = client.post(url, json={"length": 30, "description": "auto"})
assert a.status_code == 201 and a.json()["prefix"] == "10.207.0.4/30" and a.json()["parent_id"] == parent["id"] and a.json()["vrf_id"] == org["vrf_id"]
assert client.post(url, json={"length": 30}).json()["prefix"] == "10.207.0.8/30"
assert client.post(url, json={"length": 29}).json()["prefix"] == "10.207.0.16/29" # выравнивание по размеру блока
assert client.post(url, json={"length": 24}).status_code == 422 and client.post(url, json={"length": 33}).status_code == 422
assert client.post(url, json={"length": 25}).json()["prefix"] == "10.207.0.128/25"
assert client.post(url, json={"length": 25}).status_code == 409 # свободной половины больше нет
assert client.get(url, params={"length": 25}).json()["prefix"] is None
from app.services import next_free_subnet
v6 = int(ipaddress.ip_address("fd00::"))
assert next_free_subnet("fd00::/64", 66, [(v6, v6 + 5)]) == "fd00::4000:0:0:0/66" # IPv6: первый блок занят, берётся второй
def test_vrf_name_unique_per_organization(client, org):
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
try:
+2 -2
View File
@@ -9,7 +9,7 @@ from tests.conftest import BASE
def test_journal_search_and_filters(client, org):
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64" # уникальный для запуска: журнал общий и накапливается
cidr = f"fd00:{uuid.uuid4().int % 0xF000 + 0x1000:x}:{uuid.uuid4().int % 0xF000 + 0x1000:x}::/64" # уникальный для запуска (журнал общий); группы без ведущих нулей — иначе PostgreSQL CIDR нормализует запись и поиск по тексту промахивается
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
hit = client.get("/audit", params={"q": cidr}).json()
assert hit["total"] >= 1
@@ -56,7 +56,7 @@ def test_clear_requires_password_and_locks_out(db):
def test_client_ip_and_request_meta_recorded(client, org):
hdr = {"User-Agent": "ipam-tests/1.0", "X-Forwarded-For": "203.0.113.9"} # подделка XFF от недоверенного пира
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64"
cidr = f"fd00:{uuid.uuid4().int % 0xF000 + 0x1000:x}:{uuid.uuid4().int % 0xF000 + 0x1000:x}::/64"
client.post("/prefixes", headers=hdr, json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
entry = client.get("/audit", params={"q": cidr}).json()["items"][0]
assert ipaddress.ip_address(entry["client_ip"])
+70
View File
@@ -0,0 +1,70 @@
"""Управление пользователями: один сквозной сценарий (создание → права → отключение → пароли → удаление)."""
import uuid
import httpx
from tests.conftest import BASE
def _client(username: str, password: str):
"""Клиент с токеном пользователя; None — вход не удался."""
c = httpx.Client(base_url=BASE, timeout=30)
r = c.post("/auth/login", json={"username": username, "password": password})
if r.status_code != 200:
c.close()
return None
c.headers["Authorization"] = "Bearer " + r.json()["access_token"]
return c
def test_users_management(client):
name, uid, other = f"qa-{uuid.uuid4().hex[:8]}", None, None
try:
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer"})
assert created.status_code == 201, created.text
uid = created.json()["id"]
assert created.json()["role"] == "viewer" and created.json()["is_active"] is True
assert client.post("/users", json={"username": name.upper(), "password": "start-pass-123"}).status_code == 409 # логин занят без учёта регистра
assert client.post("/users", json={"username": "system", "password": "start-pass-123"}).status_code == 422 # служебный логин журнала
assert client.post("/users", json={"username": "ab", "password": "start-pass-123"}).status_code == 422 # короткий логин
assert client.post("/users", json={"username": "short-pw", "password": "123"}).status_code == 422 # короткий пароль
other = _client(name, "start-pass-123")
assert other is not None
assert other.get("/auth/me").json()["role"] == "viewer"
assert other.get("/users").status_code == 403 # список пользователей только для админа
assert other.post("/organizations", json={"name": "qa", "inn": "1234567890"}).status_code == 403 # роль «просмотр» — только чтение
# отключение действует немедленно, включая ранее выданный токен
assert client.patch(f"/users/{uid}", json={"is_active": False}).json()["is_active"] is False
assert other.get("/auth/me").status_code == 401
assert _client(name, "start-pass-123") is None
# возврат доступа и сброс пароля администратором
client.patch(f"/users/{uid}", json={"is_active": True, "password": "reset-pass-123"})
assert _client(name, "start-pass-123") is None
other = _client(name, "reset-pass-123")
assert other is not None
# смена своего пароля: нужен текущий, новый не должен совпадать с ним
assert other.post("/users/me/password", json={"current_password": "wrong", "new_password": "third-pass-123"}).status_code == 403
assert other.post("/users/me/password", json={"current_password": "reset-pass-123", "new_password": "reset-pass-123"}).status_code == 422
assert other.post("/users/me/password", json={"current_password": "reset-pass-123", "new_password": "third-pass-123"}).status_code == 200
assert _client(name, "third-pass-123") is not None
assert other.get("/auth/me").status_code == 200 # выданный ранее токен продолжает работать
# свою учётную запись удалить или отключить нельзя
me = client.get("/auth/me").json()
assert client.delete(f"/users/{me['id']}").status_code == 409
assert client.patch(f"/users/{me['id']}", json={"is_active": False}).status_code == 409
assert client.patch(f"/users/{uid}", json={"role": "admin"}).json()["role"] == "admin"
assert client.delete(f"/users/{uid}").status_code == 204
uid = None
assert client.get("/audit", params={"event_type": "user.created"}).json()["total"] >= 1
assert client.get("/audit", params={"event_type": "user.password_reset"}).json()["total"] >= 1
finally:
if other is not None:
other.close()
if uid is not None:
client.delete(f"/users/{uid}")
+230 -23
View File
@@ -87,6 +87,27 @@ const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =
const searchBox = (value, placeholder, width) =>
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
const isAdmin = () => user?.role === "admin";
const ROLE_RU = { admin: "Администратор", viewer: "Просмотр" };
/* ------------------------------------------------ выбор строк и групповые операции (только admin) */
const selSet = () => (S.sel ||= new Set());
const selCols = (cols) => (isAdmin() ? "40px " + cols : cols);
function setSelectable(ids) { // выбираемые строки экрана; выделение усекается до присутствующих (фильтр/поиск не оставляют «невидимых»)
S.selectable = ids;
const keep = new Set(ids);
S.sel = new Set([...selSet()].filter((x) => keep.has(x)));
}
const selCell = (id, disabled = false) => (isAdmin()
? `<label class="sel-cell"><input type="checkbox" id="sel-${id}" data-action="sel" data-id="${id}" aria-label="Выбрать" ${selSet().has(id) ? "checked" : ""} ${disabled ? "disabled" : ""}></label>` : "");
const selBlank = () => (isAdmin() ? "<span></span>" : "");
const selAllCell = () => (isAdmin()
? `<label class="sel-cell"><input type="checkbox" id="sel-all" data-action="sel-all" aria-label="Выбрать все" ${S.selectable?.length && selSet().size === S.selectable.length ? "checked" : ""}></label>` : "");
const selRow = (id) => (selSet().has(id) ? " selected" : "");
const bulkMenu = (id, label, items) =>
`<span class="rel"><button type="button" class="btn" data-action="menu" data-menu="${id}">${label}${I.chevD(14)}</button>${popMenu(id, items)}</span>`;
const bulkBar = (extra = "") => (isAdmin() && selSet().size
? `<div class="bulkbar"><b>Выбрано: ${selSet().size}</b>${extra}${btn("Удалить", "bulk-del", { cls: "danger", icon: I.trash(14) })}<span class="grow"></span>${btn("Снять выделение", "sel-clear", { cls: "ghost" })}</div>` : "");
function popMenu(id, items, extra = "") {
if (menu?.id !== id) return "";
@@ -103,11 +124,12 @@ function orgSwitcher() {
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"]];
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]];
function shell(active, content) {
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
<nav class="nav">${NAV.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div><span class="user">${esc(user?.username ?? "")}</span>${btn("Выйти", "logout", { cls: "ghost" })}</div>
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div><span class="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</div>
<div class="page">${content}</div>`;
}
@@ -117,7 +139,7 @@ function openDialog({ title, width = 560, note = "", body, foot }) {
<div class="dialog-head"><h2>${esc(title)}</h2>${iconBtn(I.close(), "close-dialog", "Закрыть")}</div>${note ? `<div class="dialog-note">${note}</div>` : ""}${body}${foot}</div></div>`;
$("#dlg-form .input, #dlg-form .select")?.focus();
}
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; };
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; S.subnetRefresh = null; };
const opt = (label) => `<span class="opt"> ${label}</span>`;
const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) =>
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}</span><input class="input${mono ? " mono" : ""}" type="${type}" name="${name}" value="${esc(value)}" placeholder="${esc(ph)}"></label>`;
@@ -168,7 +190,7 @@ const screens = {};
screens.overview = async () => {
const o = await api("/overview");
const [, tc] = utilColor(o.utilization);
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"] };
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"], delete_blocked: ["amber", "отклонено"] };
const top = o.top_prefixes.map((p, i, a) => {
const [bar, txt] = utilColor(p.utilization);
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
@@ -194,14 +216,16 @@ screens.orgs = async () => {
const q = S.q || "";
const { items, total } = await api("/organizations", { params: { q, limit: 500 } });
const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total;
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px">
setSelectable(items.map((o) => o.id));
const cols = selCols("minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px");
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
S.rows = items;
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
<div class="tr th" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px"><span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
};
function orgDialog(o) {
@@ -229,15 +253,16 @@ screens.isps = async () => {
const { items } = await api("/isps", { params: { q, limit: 500 } });
const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length;
S.rows = items;
const cols = "minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px";
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}"><span style="font-weight:500">${esc(i.name)}</span>
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
setSelectable(items.map((i) => i.id));
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
<div class="tr th" style="--cols:${cols}"><span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
};
function ispDialog(i) {
@@ -268,7 +293,8 @@ screens.devices = async () => {
]);
S.types = types.items;
S.rows = list.items;
const cols = "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px";
const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px");
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) => {
@@ -276,14 +302,14 @@ screens.devices = async () => {
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 class="muted" style="font-size:13px">0</span>`;
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}"><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="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("");
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
<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="tr th" style="--cols:${cols}"><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>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>`);
};
function deviceDialog(d) {
@@ -351,7 +377,8 @@ screens.prefixes = async () => {
const byId = Object.fromEntries(all.map((p) => [p.id, p]));
const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; };
const visible = filtered.filter((p) => !hidden(p));
const cols = "44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px";
const cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
const rows = visible.map((p, n) => {
const [bar, txt] = utilColor(p.utilization);
@@ -360,16 +387,16 @@ screens.prefixes = async () => {
const chev = hasKids.has(p.id) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
const pad = p.depth * 20;
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
<span class="cell-actions rel">${leaf ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }, { label: "Удалить", value: "del:" + p.id, cls: "danger" }], "row-pop") : ""}</span></div>`;
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
}).join("");
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
<div class="tr th" style="--cols:${cols};--h:40px"><span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</div></div>`);
};
function prefixDialog(p) {
@@ -400,6 +427,45 @@ ${fArea("note", "Примечание", { value: p?.note, ph: "Любая доп
foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"),
});
}
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
function subnetDialog(parent) {
const plen = Number(parent.prefix.split("/")[1]), max = parent.family === 6 ? 128 : 32, lo = plen + 1;
if (lo > max) return toast("Префикс нельзя дробить: это одиночный адрес", true);
const kids = (S.prefixes || []).filter((x) => x.parent_id === parent.id);
const lastKid = kids.reduce((a, b) => (a && a.id > b.id ? a : b), null);
const def = lastKid ? Number(lastKid.prefix.split("/")[1]) : Math.min(Math.max(parent.family === 6 ? 64 : 30, lo), max);
const sizes = Array.from({ length: max - lo + 1 }, (_, i) => ({ value: lo + i, label: "/" + (lo + i) }));
S.dialog = async (v) => {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { method: "POST", body: { length: Number(v.length), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
S.collapsed?.delete(parent.id); // показать созданный префикс под родителем
toast(`Создан префикс ${r.prefix}`);
await draw();
};
let seq = 0;
S.subnetRefresh = async () => { // предпросмотр: какой блок будет выделен
const len = $("#dlg-form [name=length]")?.value, box = $("#sn-preview"), go = $("#modal-root [data-action=submit-form]"), n = ++seq;
if (!len || !box) return;
let text, ok = false;
try {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { params: { length: len } });
ok = !!r.prefix; text = ok ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
} catch (e) { text = esc(e.message); }
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
$("#sn-preview").innerHTML = text; go.disabled = !ok;
};
openDialog({
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
body: formBody(`${fSelect("length", "Размер", sizes, def)}
<div class="info" id="sn-preview">Подбираем блок…</div>
${fInput("description", "Описание", { value: "", optional: true })}
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
${fArea("note", "Примечание", { value: "" })}
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
foot: dlgFoot("Создать префикс"),
});
S.subnetRefresh();
}
async function vrfsDialog() {
const org = currentOrg();
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
@@ -438,13 +504,14 @@ 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 = "minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px";
const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 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" }];
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px"><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 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("");
@@ -458,7 +525,7 @@ screens.address = async (id) => {
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#2450c8"></div><div style="width:${pct(sm.reserved)}%;background:#d97706"></div></div></div>
<div class="legend"><span><i style="background:#2450c8"></i>Назначено ${sm.assigned}</span><span><i style="background:#d97706"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:#dde3ea"></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="tr th" style="--cols:${cols};--h:40px"><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>Изменён (UTC)</span><span></span></div>
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
};
function addressDialog(a, presetIp = "") {
@@ -487,14 +554,13 @@ ${fArea("note", "Примечание", { value: a?.note })}`),
}
// ---- journal (audit)
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", session: "Сессия", journal: "Журнал" };
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", user: "Пользователь", session: "Сессия", journal: "Журнал" };
const eventBadge = (ev) => {
const [entity, action] = ev.split(".");
const cls = { created: "blue", assigned: "green", updated: "amber", deleted: "red", failed: "red" }[action] || "";
const cls = { created: "blue", assigned: "green", updated: "amber", password_reset: "amber", deleted: "red", failed: "red", delete_blocked: "amber" }[action] || "";
return badge(cls, ev);
};
const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—");
const isAdmin = () => user?.role === "admin";
screens.journal = async () => {
const limit = S.limit || 100;
@@ -594,6 +660,75 @@ async function doClear() {
}
}
// ---- users
screens.users = async () => {
if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
const q = S.q || "";
const { items, total } = await api("/users", { params: { q, limit: 500 } });
S.rows = items;
const cols = selCols("minmax(200px,1.5fr) 190px 190px minmax(140px,1fr) 44px");
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
const rows = items.map((u, n) => {
const self = u.id === user?.id;
const items2 = [
{ label: "Редактировать", value: "edit:" + u.id },
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
];
return `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
<span>${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
}).join("");
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Статус</span><span></span><span></span></div>
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
};
function userDialog(u) {
const edit = !!u;
const self = edit && u.id === user?.id;
S.dialog = async (v) => {
if (edit) {
const body = { is_active: v.is_active === true };
if (v.role) body.role = v.role;
if (v.password) body.password = v.password;
await api(`/users/${u.id}`, { method: "PATCH", body });
} else {
await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } });
}
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
await draw();
};
openDialog({
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.",
body: formBody(`${edit ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
});
}
function passwordDialog() {
S.dialog = async (v) => {
if (v.new_password !== v.confirm_password) throw new ApiError(422, { message: "Пароли не совпадают", fields: { confirm_password: "пароли не совпадают" } });
await api("/users/me/password", { method: "POST", body: { current_password: v.current_password, new_password: v.new_password } });
toast("Пароль изменён");
};
openDialog({
title: "Сменить пароль",
note: `Учётная запись: ${esc(user?.username ?? "")}`,
body: formBody(`${fInput("current_password", "Текущий пароль", { type: "password" })}
<div class="grid2">${fInput("new_password", "Новый пароль", { type: "password", hint: "минимум 8 символов" })}${fInput("confirm_password", "Повторите пароль", { type: "password" })}</div>
<div class="info">Смена пароля не завершает уже открытые сессии: выданный ранее токен действует до истечения своего срока.</div>`),
foot: dlgFoot("Сохранить пароль"),
});
}
// ---- login
function loginScreen(err = "") {
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
@@ -629,6 +764,8 @@ async function draw() {
const active = document.activeElement;
const focusId = active?.id, caret = active?.selectionStart;
app.innerHTML = await fn();
const all = $("#sel-all");
if (all) all.indeterminate = selSet().size > 0 && selSet().size < S.selectable.length;
if (focusId) { const el = document.getElementById(focusId); if (el) { el.focus(); try { el.setSelectionRange(caret, caret); } catch { /* ignore */ } } }
} catch (e) {
if (e instanceof ApiError && e.status === 401) return;
@@ -644,6 +781,52 @@ async function guarded(fn) {
}
const rowById = (id) => S.rows?.find((r) => r.id === Number(id));
// групповые операции: последовательные вызовы существующих DELETE/PATCH; отказ по одному объекту не прерывает остальные
const BULK = {
orgs: { forms: ["организация", "организации", "организаций"], url: (id) => `/organizations/${id}`, label: (id) => rowById(id)?.name, after: loadOrgs },
isps: { forms: ["оператор", "оператора", "операторов"], url: (id) => `/isps/${id}`, label: (id) => rowById(id)?.name },
devices: { forms: ["устройство", "устройства", "устройств"], url: (id) => `/devices/${id}`, label: (id) => rowById(id)?.name },
prefixes: { forms: ["префикс", "префикса", "префиксов"], url: (id) => `/prefixes/${id}`, label: (id) => S.prefixes.find((p) => p.id === id)?.prefix },
address: { forms: ["адрес", "адреса", "адресов"], url: (id) => `/addresses/${id}`, label: (id) => S.page.items.find((a) => a.id === id)?.address },
users: { forms: ["пользователь", "пользователя", "пользователей"], url: (id) => `/users/${id}`, label: (id) => rowById(id)?.username },
};
const screenKey = () => { const { path, arg } = route(); return path === "prefixes" && arg ? "address" : path; };
function failuresDialog(title, failed) {
openDialog({
title: "Выполнено не для всех объектов", width: 640,
note: `${esc(title)}: не выполнено ${failed.length}. Эти объекты остались выбранными.`,
body: `<div class="dialog-body"><div class="mini-table" style="max-height:320px;overflow:auto">${failed.map(([label, msg]) =>
`<div class="tr" style="--cols:minmax(120px,1fr) 2fr;height:auto;min-height:48px;padding:10px 12px"><span class="m13" style="font-weight:600;overflow-wrap:anywhere">${esc(label)}</span><span class="muted" style="overflow-wrap:anywhere">${esc(msg)}</span></div>`).join("")}</div></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "primary" })}</div>`,
});
}
async function bulk(title, ids, labelOf, call, after) {
const st = S;
if (st.busy || !ids.length) return;
st.busy = true;
const failed = [];
let ok = 0;
try {
for (const id of ids) {
try { await call(id); ok++; st.sel.delete(id); } catch (e) {
if (!(e instanceof ApiError)) throw e;
if (e.status === 401) return;
failed.push([labelOf(id) ?? `#${id}`, e.message]);
}
}
} finally { st.busy = false; }
toast(`${title}: выполнено ${ok} из ${ids.length}`, failed.length > 0);
await after?.();
if (S !== st) return; // пока шли запросы, пользователь ушёл на другой экран
await draw();
if (failed.length) failuresDialog(title, failed);
}
function bulkPick(id, value) {
const k = screenKey(), ids = [...selSet()];
if (id === "bulk-type") return bulk("Смена типа", ids, BULK.devices.label, (x) => api(`/devices/${x}`, { method: "PATCH", body: { device_type_id: Number(value) } }));
return bulk("Смена статуса", ids, BULK[k].label, (x) => api(BULK[k].url(x), { method: "PATCH", body: { status: value } }));
}
const rowActions = {
"org-row": async (act, id) => {
if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; }
@@ -661,6 +844,7 @@ const rowActions = {
"pfx-row": async (act, id) => {
if (act === "open") location.hash = `#/prefixes/${id}`;
else if (act === "edit") prefixDialog(S.prefixes.find((p) => p.id === Number(id)));
else if (act === "subnet") subnetDialog(S.prefixes.find((p) => p.id === Number(id)));
else if (act === "del" && confirmDel("префикс")) await guarded(async () => {
try { await api(`/prefixes/${id}`, { method: "DELETE" }); } catch (e) {
if (e.status === 409 && confirm(`${e.message}. Удалить вместе с адресами?`)) await api(`/prefixes/${id}?force=true`, { method: "DELETE" }); else throw e;
@@ -673,6 +857,15 @@ const rowActions = {
else if (act === "edit") addressDialog(S.page.items.find((a) => a.id === Number(id)));
else if (act === "del" && confirmDel("адрес")) await guarded(async () => { await api(`/addresses/${id}`, { method: "DELETE" }); toast("Адрес удалён"); await draw(); });
},
"user-row": async (act, id) => {
const u = rowById(id);
if (act === "edit") userDialog(u);
else if (act === "toggle") await guarded(async () => {
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
});
else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
},
};
const actions = {
@@ -689,6 +882,7 @@ const actions = {
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
else if (id === "jactor") { S.jactor = value; S.limit = 100; }
else if (id === "jentity") { S.jentity = value; S.limit = 100; }
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
else {
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
const [act, rid] = value.split(":");
@@ -716,10 +910,20 @@ const actions = {
box.querySelector(".select").focus();
input.dispatchEvent(new Event("change", { bubbles: true }));
},
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
"sel-clear": () => { S.sel = new Set(); draw(); },
"bulk-del": () => {
const c = BULK[screenKey()], ids = [...selSet()];
if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after);
},
"bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label,
(id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })),
"pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; },
"org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; },
"isp-edit": (d) => ispDialog(rowById(d.id)),
"dev-edit": (d) => deviceDialog(rowById(d.id)),
"user-edit": (d) => userDialog(rowById(d.id)),
"adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
"adr-assign": (d) => addressDialog(null, d.ip),
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
@@ -733,6 +937,8 @@ const actions = {
"submit-form": submitDialog,
"open-org": (d) => { store.orgId = Number(d.id); },
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"isp-new": () => ispDialog(null),
"dev-new": () => deviceDialog(null),
"pfx-new": () => prefixDialog(null),
@@ -797,6 +1003,7 @@ document.addEventListener("input", (e) => {
});
document.addEventListener("change", (e) => {
if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }
if (e.target.name === "length" && S.subnetRefresh) { S.subnetRefresh(); return; }
const hint = $("#vrf-hint");
if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original;
});
+7
View File
@@ -182,3 +182,10 @@ button.select.ph{color:var(--faint)}
.warn{padding:10px 12px;border-radius:8px;background:#fdf0d5;color:#7c4a00;font:400 13px/1.4 var(--sans)}
.btn.danger:disabled{background:#f4f6f9;color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1}
.tr.clickable:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}
.sel-cell{display:flex;align-items:center;justify-content:center;height:100%;cursor:pointer}
.sel-cell input{width:16px;height:16px;margin:0;accent-color:var(--primary);cursor:pointer}
.sel-cell input:disabled{cursor:not-allowed}
.tr.selected,.tr.selected:hover{background:#eef3ff}
.bulkbar{display:flex;align-items:center;gap:8px;height:48px;padding:0 16px;background:#f3f7ff;border-top:1px solid var(--line);font:400 14px/1 var(--sans);position:relative}
.bulkbar .pop{top:40px}