Задача 042: свёртка свободных адресов в раскрывающиеся диапазоны
В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку «начало – конец · Свободно: N» (параметр group_free, группировка на сервере — пагинация по строкам после свёртки); раскрытие — существующим status=free&offset=free_offset порциями по 500. Без group_free ответ API прежний. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9301069c29
commit
d1a2efd683
7 files changed
+176
-12
No files matched your search
@@ -86,7 +86,7 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
| Вход | `POST /auth/login`, `POST /auth/login/2fa` (изменение 037), `GET /auth/me` |
|
| Вход | `POST /auth/login`, `POST /auth/login/2fa` (изменение 037), `GET /auth/me` |
|
||||||
| Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` |
|
| Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` |
|
||||||
| Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
|
| Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
|
||||||
| Адреса | `GET\|POST /prefixes/{id}/addresses`, `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` |
|
| Адреса | `GET\|POST /prefixes/{id}/addresses` (`group_free=true` сворачивает подряд идущие свободные адреса в строки `free_range`, изменение 042), `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` |
|
||||||
| Пользователи | `/users`, `POST /users/me/password`, `POST /users/me/2fa/setup\|enable\|disable`, `POST /users/{id}/2fa/reset` (изменение 037) |
|
| Пользователи | `/users`, `POST /users/me/password`, `POST /users/me/2fa/setup\|enable\|disable`, `POST /users/{id}/2fa/reset` (изменение 037) |
|
||||||
| Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` |
|
| Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` |
|
||||||
| Сводка | `GET /overview` |
|
| Сводка | `GET /overview` |
|
||||||
@@ -157,6 +157,7 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
|
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
|
||||||
- Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера.
|
- Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера.
|
||||||
- «Устройства»: колонка «Статус» (бейдж) и поле «Статус» в диалоге добавления/редактирования; «Адреса» подсети: колонки «Тип устройства», «Устройство», «Статус устройства» после «Описание» (изменение 039).
|
- «Устройства»: колонка «Статус» (бейдж) и поле «Статус» в диалоге добавления/редактирования; «Адреса» подсети: колонки «Тип устройства», «Устройство», «Статус устройства» после «Описание» (изменение 039).
|
||||||
|
- «Адреса» подсети: подряд идущие свободные адреса свёрнуты в одну строку-диапазон («10.0.0.5 – 10.0.0.20 · Свободно: 16»); клик раскрывает её в отдельные адреса с действием «Назначить адрес», повторный клик сворачивает. Диапазоны длиннее 500 адресов подгружаются порциями по клику «ещё N — показать» (изменение 042).
|
||||||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
||||||
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
||||||
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
||||||
@@ -213,6 +214,7 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
|||||||
| 039 | Статус устройства и сведения об устройстве в списке адресов префикса | [план](docs/changes/039-device-status/PLAN.md) · [итог](docs/changes/039-device-status/SUMMARY.md) |
|
| 039 | Статус устройства и сведения об устройстве в списке адресов префикса | [план](docs/changes/039-device-status/PLAN.md) · [итог](docs/changes/039-device-status/SUMMARY.md) |
|
||||||
| 040 | Название в UI: «IPAM Manager» | [план](docs/changes/040-brand-name/PLAN.md) · [итог](docs/changes/040-brand-name/SUMMARY.md) |
|
| 040 | Название в UI: «IPAM Manager» | [план](docs/changes/040-brand-name/PLAN.md) · [итог](docs/changes/040-brand-name/SUMMARY.md) |
|
||||||
| 041 | Favicon из логотипа бренда | [план](docs/changes/041-favicon/PLAN.md) · [итог](docs/changes/041-favicon/SUMMARY.md) |
|
| 041 | Favicon из логотипа бренда | [план](docs/changes/041-favicon/PLAN.md) · [итог](docs/changes/041-favicon/SUMMARY.md) |
|
||||||
|
| 042 | Свёртка свободных адресов в раскрывающиеся диапазоны | [план](docs/changes/042-free-ranges/PLAN.md) · [итог](docs/changes/042-free-ranges/SUMMARY.md) |
|
||||||
|
|
||||||
## Отчёты ревью
|
## Отчёты ревью
|
||||||
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||||||
|
|||||||
+33
-2
@@ -380,11 +380,13 @@ def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None
|
|||||||
|
|
||||||
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
|
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
|
||||||
def list_addresses(
|
def list_addresses(
|
||||||
id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
id: int, status: str = "", q: str = "", group_free: bool = False, limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||||
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||||
):
|
):
|
||||||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
|
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
|
||||||
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
|
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети).
|
||||||
|
group_free (изменение 042): только в смешанном режиме — подряд идущие свободные адреса сворачиваются
|
||||||
|
в строки free_range; без параметра поведение и ответ побайтно прежние (совместимость, тесты)."""
|
||||||
p = get_or_404(db, Prefix, id, "Префикс")
|
p = get_or_404(db, Prefix, id, "Префикс")
|
||||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||||
cap = capacity(str(p.prefix))
|
cap = capacity(str(p.prefix))
|
||||||
@@ -404,6 +406,33 @@ def list_addresses(
|
|||||||
def free_rows(ips: list[str]) -> list[s.AddressOut]:
|
def free_rows(ips: list[str]) -> list[s.AddressOut]:
|
||||||
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
|
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
|
||||||
|
|
||||||
|
def group_free_rows(rows: list[s.AddressOut]) -> list[s.AddressOut]:
|
||||||
|
"""Изменение 042: подряд идущие строки status=free (серия ≥ 2) сворачиваются в одну строку free_range.
|
||||||
|
Свёртка — арифметически по уже отсортированному списку (free-строки идут подряд после сортировки),
|
||||||
|
без повторного перебора подсети. free_offset — число свободных адресов подсети до начала диапазона,
|
||||||
|
совпадает с offset для GET .../addresses?status=free&offset=…&limit=… (free_page считает так же)."""
|
||||||
|
out: list[s.AddressOut] = []
|
||||||
|
free_before, i, n = 0, 0, len(rows)
|
||||||
|
while i < n:
|
||||||
|
if rows[i].status != "free":
|
||||||
|
out.append(rows[i])
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
j = i
|
||||||
|
while j < n and rows[j].status == "free":
|
||||||
|
j += 1
|
||||||
|
run = j - i
|
||||||
|
if run >= 2:
|
||||||
|
out.append(s.AddressOut(
|
||||||
|
id=None, prefix_id=id, address=rows[i].address, status="free_range",
|
||||||
|
range_end=rows[j - 1].address, range_count=run, free_offset=free_before,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
out.append(rows[i])
|
||||||
|
free_before += run
|
||||||
|
i = j
|
||||||
|
return out
|
||||||
|
|
||||||
if status == "free":
|
if status == "free":
|
||||||
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
|
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
|
||||||
|
|
||||||
@@ -423,6 +452,8 @@ def list_addresses(
|
|||||||
if mixed:
|
if mixed:
|
||||||
rows = [_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in db.execute(stmt.order_by(Address.address)).all()]
|
rows = [_addr_out(a, dn, dtn, ds.value if ds else None) for a, dn, dtn, ds in db.execute(stmt.order_by(Address.address)).all()]
|
||||||
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
|
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
|
||||||
|
if group_free: # изменение 042
|
||||||
|
rows = group_free_rows(rows)
|
||||||
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
|
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
|
||||||
total = count(db, select(Address.id).where(*flt))
|
total = count(db, select(Address.id).where(*flt))
|
||||||
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
|
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
|
||||||
|
|||||||
+5
-1
@@ -386,7 +386,7 @@ class AddressOut(BaseModel):
|
|||||||
id: int | None
|
id: int | None
|
||||||
prefix_id: int
|
prefix_id: int
|
||||||
address: str
|
address: str
|
||||||
status: str # assigned | reserved | deprecated | free
|
status: str # assigned | reserved | deprecated | free | free_range (изменение 042: свёрнутый диапазон свободных адресов)
|
||||||
dns_name: str = ""
|
dns_name: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
device_id: int | None = None
|
device_id: int | None = None
|
||||||
@@ -395,6 +395,10 @@ class AddressOut(BaseModel):
|
|||||||
device_status: str | None = None # изменение 039
|
device_status: str | None = None # изменение 039
|
||||||
note: str = ""
|
note: str = ""
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
# изменение 042: заполняются только для status=free_range; address/range_end — первый/последний адрес диапазона
|
||||||
|
range_end: str | None = None
|
||||||
|
range_count: int | None = None
|
||||||
|
free_offset: int | None = None # число свободных адресов подсети до начала диапазона (offset для GET .../addresses?status=free)
|
||||||
|
|
||||||
|
|
||||||
class AddressSummary(BaseModel):
|
class AddressSummary(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Свёртка свободных адресов в раскрывающиеся диапазоны (изменение 042)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
В списке адресов префикса (`screens.address`) для подсетей до /20 занятые и свободные адреса идут вперемешку, каждый свободный — отдельной строкой.
|
||||||
|
В почти пустой /24 это ~250 строк «Свободен», которые оттесняют занятые адреса. Нужно сворачивать **подряд идущие свободные адреса**
|
||||||
|
в одну строку-диапазон: «10.0.0.5 – 10.0.0.20 · свободно 16». По клику строка раскрывается в отдельные адреса, повторный клик сворачивает.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Группировка на сервере. Если сворачивать на клиенте в пределах страницы, диапазоны рвутся на границе пагинации,
|
||||||
|
а страница в 100 строк почти целиком занята свободными. С группировкой на сервере страница из 100 строк показывает 100 занятых адресов или диапазонов.
|
||||||
|
|
||||||
|
### API — `app/api/v1/prefixes.py::list_addresses`
|
||||||
|
- Новый параметр `group_free: bool = False`: по умолчанию поведение прежнее (совместимость, тесты).
|
||||||
|
Действует только в смешанном режиме (`mixed`: без фильтра статуса и поиска, подсеть до `FREE_LISTING_LIMIT`).
|
||||||
|
- Подряд идущие свободные адреса (серия ≥ 2) сворачиваются в одну строку `AddressOut`:
|
||||||
|
- `status="free_range"`, `address` — первый адрес, `range_end` — последний;
|
||||||
|
- `range_count` — число адресов;
|
||||||
|
- `free_offset` — сколько свободных адресов подсети идёт до начала диапазона.
|
||||||
|
Одиночный свободный адрес остаётся обычной строкой `free`. `total` и пагинация считаются по строкам после свёртки.
|
||||||
|
- `app/schemas.py::AddressOut`: `range_end: str | None = None`, `range_count: int | None = None`, `free_offset: int | None = None`.
|
||||||
|
- Раскрытие диапазона — существующий `GET …/addresses?status=free&offset=<free_offset>&limit=<n>`:
|
||||||
|
`free_page` уже считает страницу свободных арифметически, новых эндпоинтов не нужно.
|
||||||
|
|
||||||
|
### UI — `web/app.js::screens.address`
|
||||||
|
- Запрос списка — с `group_free: true`.
|
||||||
|
- Строка `free_range`: шеврон ▸/▾, текст «<начало> – <конец>», в колонке статуса — бейдж «Свободно: N». Остальные колонки — «—».
|
||||||
|
Действия «Назначить адрес» у свёрнутой строки нет: назначают из раскрытых строк. Строка не выбирается чекбоксом.
|
||||||
|
- Клик по строке диапазона раскрывает или сворачивает её. Раскрытое состояние — `S.freeOpen`: множество начальных адресов,
|
||||||
|
сбрасывается при смене префикса, фильтра или поиска.
|
||||||
|
При раскрытии: `api(…/addresses, {status: "free", offset: free_offset, limit: min(range_count, 500)})`, строки вставляются под диапазоном
|
||||||
|
с отступом, как обычные строки «Свободен», с действием «Назначить адрес».
|
||||||
|
Если в диапазоне больше 500 адресов, после первых 500 показывается строка «ещё N — показать», она подгружает следующую порцию.
|
||||||
|
- «Показано X из Y» учитывает строки после свёртки.
|
||||||
|
- Стиль — существующие классы и токены (`.tr.free`, `badge`, `I.chevR`/`I.chevD` как в дереве префиксов); литеральных цветов нет, тёмная тема работает.
|
||||||
|
|
||||||
|
## Тест (минимально)
|
||||||
|
В существующем тесте `tests/test_api.py`, работающем с адресами малой подсети (например, `test_tree_utilization_and_next_free`), проверить:
|
||||||
|
`GET /prefixes/{leaf}/addresses?group_free=true` → строка `free_range` с корректными `address`, `range_end`, `range_count`, `free_offset`;
|
||||||
|
`status=free&offset=<free_offset>&limit=<range_count>` возвращает ровно адреса этого диапазона.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
- `README.md`: «Адреса» и «Интерфейс» — свёртка свободных; строка 042 в истории.
|
||||||
|
- `SUMMARY.md` — по завершении.
|
||||||
|
|
||||||
|
## Исполнение
|
||||||
|
По принятой схеме: код и тест пишет агент на Sonnet, он же пересобирает стенд с `--force-recreate`. Моя часть — ревью и `pytest`.
|
||||||
|
UI проверяет пользователь.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `pytest -q` — все зелёные.
|
||||||
|
- API: `/24` с парой назначенных адресов → 3–5 строк вместо ~254; `free_offset` и раскрытие дают ровно адреса диапазона; IPv6 `/120` — тоже.
|
||||||
|
- Без `group_free` ответ идентичен прежнему.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Итог: свёртка свободных адресов в раскрывающиеся диапазоны (изменение 042)
|
||||||
|
|
||||||
|
План: `PLAN.md`. В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку-диапазон
|
||||||
|
«начало – конец · Свободно: N»; клик раскрывает её в отдельные адреса, повторный — сворачивает. Одиночный свободный адрес — обычная строка.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `app/api/v1/prefixes.py` | `list_addresses(group_free=False)`: в смешанном режиме (без фильтра и поиска, подсеть ≤ `FREE_LISTING_LIMIT`) серии свободных ≥ 2 сворачиваются одним проходом по отсортированному списку в строку `free_range`. `total` и пагинация — по строкам после свёртки. Без `group_free` ответ прежний |
|
||||||
|
| `app/schemas.py` | `AddressOut.range_end`, `range_count`, `free_offset` |
|
||||||
|
| `web/app.js` | Список запрашивается с `group_free`. Строка диапазона: шеврон как в дереве префиксов, бейдж «Свободно: N», без чекбокса и действий. Раскрытие — существующий `status=free&offset=free_offset`, порциями по 500 со строкой «ещё N — показать». Состояние в `S.freeOpen`/`S.freeLoaded`, сброс при смене фильтра, поиска или префикса. Подсказка адреса в «Назначить адрес» учитывает `free_range` |
|
||||||
|
| `tests/test_api.py` | В `test_tree_utilization_and_next_free`: `free_range` с корректными границами и `free_offset`; раскрытие даёт ровно адреса диапазона; без `group_free` ответ не изменился |
|
||||||
|
| `README.md` | Свёртка свободных адресов; строка 042 |
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
- `pytest -q` — 17 passed.
|
||||||
|
- На стенде (временная организация, удалена):
|
||||||
|
- `10.242.0.0/24` с 3 назначенными — 5 строк вместо 254;
|
||||||
|
- `fd00:242::/120` с 3 назначенными — 5 строк вместо 256;
|
||||||
|
- раскрытие каждого диапазона через `status=free&offset&limit` совпадает по первому и последнему адресу и количеству.
|
||||||
|
- UI — проверка пользователя.
|
||||||
@@ -35,6 +35,16 @@ def test_tree_utilization_and_next_free(client, org):
|
|||||||
client.post(f"/prefixes/{leaf['id']}/addresses", json={"address": "10.203.1.2", "status": "reserved"})
|
client.post(f"/prefixes/{leaf['id']}/addresses", json={"address": "10.203.1.2", "status": "reserved"})
|
||||||
page = client.get(f"/prefixes/{leaf['id']}/addresses").json()
|
page = client.get(f"/prefixes/{leaf['id']}/addresses").json()
|
||||||
assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6}
|
assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6}
|
||||||
|
|
||||||
|
# изменение 042: подряд идущие свободные адреса (.3–.6) сворачиваются в одну строку free_range;
|
||||||
|
# раскрытие — тот же GET .../addresses?status=free&offset=<free_offset>&limit=<range_count>
|
||||||
|
grouped = client.get(f"/prefixes/{leaf['id']}/addresses", params={"group_free": "true"}).json()
|
||||||
|
rng = next(x for x in grouped["items"] if x["status"] == "free_range")
|
||||||
|
assert rng["address"] == "10.203.1.3" and rng["range_end"] == "10.203.1.6" and rng["range_count"] == 4 and rng["free_offset"] == 0
|
||||||
|
expanded = client.get(f"/prefixes/{leaf['id']}/addresses", params={"status": "free", "offset": rng["free_offset"], "limit": rng["range_count"]}).json()
|
||||||
|
assert [x["address"] for x in expanded["items"]] == ["10.203.1.3", "10.203.1.4", "10.203.1.5", "10.203.1.6"]
|
||||||
|
assert client.get(f"/prefixes/{leaf['id']}/addresses").json() == page # без group_free ответ прежний
|
||||||
|
|
||||||
assert client.post(f"/prefixes/{leaf['id']}/addresses/next").json()["address"] == "10.203.1.3"
|
assert client.post(f"/prefixes/{leaf['id']}/addresses/next").json()["address"] == "10.203.1.3"
|
||||||
parent = client.get(f"/prefixes/{parent['id']}").json()
|
parent = client.get(f"/prefixes/{parent['id']}").json()
|
||||||
assert parent["capacity"] == 65534 and parent["used"] == 2 # ёмкость родителя — размер его подсети, used — по поддереву (изменение 025)
|
assert parent["capacity"] == 65534 and parent["used"] == 2 # ёмкость родителя — размер его подсети, used — по поддереву (изменение 025)
|
||||||
|
|||||||
+52
-8
@@ -561,30 +561,60 @@ screens.address = async (id) => {
|
|||||||
if (store.orgId !== p.organization_id) store.orgId = p.organization_id;
|
if (store.orgId !== p.organization_id) store.orgId = p.organization_id;
|
||||||
const limit = S.limit || 100;
|
const limit = S.limit || 100;
|
||||||
const [page, plist, devs, vrfs] = await Promise.all([
|
const [page, plist, devs, vrfs] = await Promise.all([
|
||||||
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit } }),
|
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit, group_free: true } }), // изменение 042: свёртка подряд идущих свободных адресов
|
||||||
loadPrefixes(p.organization_id),
|
loadPrefixes(p.organization_id),
|
||||||
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
|
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
|
||||||
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
|
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
|
||||||
]);
|
]);
|
||||||
S.prefix = p; S.prefixes = plist.items; S.devices = devs.items; S.vrfs = vrfs.items; S.page = page; S.rows = page.items;
|
S.prefix = p; S.prefixes = plist.items; S.devices = devs.items; S.vrfs = vrfs.items; S.page = page; S.rows = page.items;
|
||||||
|
// изменение 042: подгрузка адресов для раскрытых диапазонов (S.freeOpen — множество начальных адресов диапазонов);
|
||||||
|
// S.freeLoaded — сколько адресов диапазона подгружено (порциями по 500, если диапазон больше)
|
||||||
|
const openRanges = page.items.filter((a) => a.status === "free_range" && S.freeOpen?.has(a.address));
|
||||||
|
const expanded = Object.fromEntries(await Promise.all(openRanges.map(async (a) => {
|
||||||
|
const want = Math.min((S.freeLoaded && S.freeLoaded[a.address]) || Math.min(a.range_count, 500), a.range_count);
|
||||||
|
const res = await api(`/prefixes/${id}/addresses`, { params: { status: "free", offset: a.free_offset, limit: want } });
|
||||||
|
return [a.address, res.items];
|
||||||
|
})));
|
||||||
const byId = Object.fromEntries(plist.items.map((x) => [x.id, x]));
|
const byId = Object.fromEntries(plist.items.map((x) => [x.id, x]));
|
||||||
const chain = [];
|
const chain = [];
|
||||||
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
|
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
|
||||||
const sm = page.summary;
|
const sm = page.summary;
|
||||||
// изменение 039: после «Описание» — «Тип устройства», «Устройство», «Статус устройства»; «Описание» и адресные колонки сужены, «Изменён» без изменений
|
// изменение 039: после «Описание» — «Тип устройства», «Устройство», «Статус устройства»; «Описание» и адресные колонки сужены, «Изменён» без изменений
|
||||||
const cols = selCols("minmax(120px,0.9fr) minmax(110px,0.8fr) minmax(110px,1fr) 120px minmax(130px,1fr) 140px 110px 160px 44px");
|
const cols = selCols("minmax(120px,0.9fr) minmax(110px,0.8fr) minmax(110px,1fr) 120px minmax(130px,1fr) 140px 110px 160px 44px");
|
||||||
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
|
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» и «диапазона» нет записи
|
||||||
const rows = page.items.map((a, n) => {
|
// изменение 042: обычная строка адреса (в т.ч. раскрытые адреса диапазона, indent — с отступом, как вложенные)
|
||||||
|
const addrRow = (a, indent = false) => {
|
||||||
const [c, t] = ADDR_STATUS[a.status];
|
const [c, t] = ADDR_STATUS[a.status];
|
||||||
const free = a.status === "free";
|
const free = a.status === "free";
|
||||||
const key = "adr-row-" + (a.id ?? a.address);
|
const key = "adr-row-" + (a.id ?? a.address);
|
||||||
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
|
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
|
||||||
const devStatus = a.device_status ? badge(...DEVICE_STATUS[a.device_status]) : `<span class="muted">—</span>`; // изменение 039
|
const devStatus = a.device_status ? badge(...DEVICE_STATUS[a.device_status]) : `<span class="muted">—</span>`; // изменение 039
|
||||||
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
|
return `<div class="tr hoverable clickable${free ? " free" : ""}${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" style="color:${free ? "var(--faint)" : "var(--text)"}${indent ? ";padding-left:20px" : ""}">${esc(a.address)}</span>
|
||||||
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="ell muted">${esc(a.description || (free ? "—" : ""))}</span>
|
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="ell muted">${esc(a.description || (free ? "—" : ""))}</span>
|
||||||
<span class="muted">${esc(a.device_type_name || "—")}</span><span class="ell muted">${esc(a.device_name || "—")}</span><span>${devStatus}</span><span>${badge(c, t)}</span>
|
<span class="muted">${esc(a.device_type_name || "—")}</span><span class="ell muted">${esc(a.device_name || "—")}</span><span>${devStatus}</span><span>${badge(c, t)}</span>
|
||||||
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
|
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
|
||||||
}).join("");
|
};
|
||||||
|
// изменение 042: свёрнутая строка диапазона свободных адресов — шеврон, «<начало> – <конец>», бейдж «Свободно: N», без действий и без чекбокса
|
||||||
|
const rangeRow = (a, open) => `<div class="tr hoverable clickable free" data-row="1" data-action="range-toggle" data-id="${esc(a.address)}" style="--cols:${cols};--h:52px">${selBlank()}<span class="m13" style="display:flex;align-items:center;gap:6px;color:var(--faint)"><span class="chev${open ? " open" : ""}">${I.chevR(14)}</span>${esc(a.address)} – ${esc(a.range_end)}</span>
|
||||||
|
<span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span>${badge("", `Свободно: ${a.range_count}`)}</span>
|
||||||
|
<span class="muted" style="font-size:13px">—</span><span></span></div>`;
|
||||||
|
// изменение 042: диапазон больше 500 — после подгруженной порции строка «ещё N — показать»
|
||||||
|
const rangeMoreRow = (a, loaded) => `<div class="tr free" style="--cols:${cols};--h:52px">${selBlank()}<span class="m13" style="padding-left:20px"><a href="#" data-action="range-more" data-id="${esc(a.address)}" data-loaded="${loaded}">ещё ${a.range_count - loaded} — показать</a></span>
|
||||||
|
<span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span></span></div>`;
|
||||||
|
const rowsArr = [];
|
||||||
|
page.items.forEach((a) => {
|
||||||
|
if (a.status === "free_range") {
|
||||||
|
const open = !!S.freeOpen?.has(a.address);
|
||||||
|
rowsArr.push(rangeRow(a, open));
|
||||||
|
if (open) {
|
||||||
|
const items = expanded[a.address] || [];
|
||||||
|
items.forEach((f) => rowsArr.push(addrRow(f, true)));
|
||||||
|
if (items.length < a.range_count) rowsArr.push(rangeMoreRow(a, items.length));
|
||||||
|
}
|
||||||
|
} else rowsArr.push(addrRow(a));
|
||||||
|
});
|
||||||
|
if (rowsArr.length) rowsArr[rowsArr.length - 1] = rowsArr[rowsArr.length - 1].replace('class="tr ', 'class="tr last ');
|
||||||
|
const rows = rowsArr.join("");
|
||||||
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
|
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
|
||||||
const shown = page.items.length;
|
const shown = page.items.length;
|
||||||
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
|
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
|
||||||
@@ -610,7 +640,8 @@ function addressDialog(a, presetIp = "") {
|
|||||||
};
|
};
|
||||||
const ipField = edit
|
const ipField = edit
|
||||||
? `<label class="field"><span>IP-адрес</span><input class="input mono" value="${esc(a.address)}" disabled></label>`
|
? `<label class="field"><span>IP-адрес</span><input class="input mono" value="${esc(a.address)}" disabled></label>`
|
||||||
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") });
|
// изменение 042: при group_free одиночных строк «free» может не быть — первый адрес свёрнутого диапазона тоже свободен
|
||||||
|
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free" || x.status === "free_range")?.address ?? "") });
|
||||||
openDialog({
|
openDialog({
|
||||||
title: edit ? "Редактировать адрес" : "Назначить адрес",
|
title: edit ? "Редактировать адрес" : "Назначить адрес",
|
||||||
body: formBody(`<div class="info">Подсеть: <b>${esc(p.prefix)}</b> · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}</div>
|
body: formBody(`<div class="info">Подсеть: <b>${esc(p.prefix)}</b> · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}</div>
|
||||||
@@ -1036,7 +1067,7 @@ const actions = {
|
|||||||
else if (id === "status") S.status = value;
|
else if (id === "status") S.status = value;
|
||||||
else if (id === "family") S.family = value;
|
else if (id === "family") S.family = value;
|
||||||
else if (id === "type") S.type = value;
|
else if (id === "type") S.type = value;
|
||||||
else if (id === "astatus") { S.astatus = value; S.limit = 100; }
|
else if (id === "astatus") { S.astatus = value; S.limit = 100; S.freeOpen = new Set(); S.freeLoaded = {}; } // изменение 042: свёрнутые диапазоны сбрасываются со сменой фильтра
|
||||||
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
|
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
|
||||||
else if (id === "jactor") { S.jactor = 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 === "jentity") { S.jentity = value; S.limit = 100; }
|
||||||
@@ -1092,6 +1123,19 @@ const actions = {
|
|||||||
"user-edit": (d) => userDialog(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-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
|
||||||
"adr-assign": (d) => addressDialog(null, d.ip),
|
"adr-assign": (d) => addressDialog(null, d.ip),
|
||||||
|
// изменение 042: свёрнутый диапазон свободных адресов — раскрыть/свернуть по клику на строку
|
||||||
|
"range-toggle": (d) => {
|
||||||
|
const open = (S.freeOpen ||= new Set());
|
||||||
|
open.has(d.id) ? open.delete(d.id) : open.add(d.id);
|
||||||
|
draw();
|
||||||
|
},
|
||||||
|
// изменение 042: диапазон больше 500 адресов — подгрузить следующую порцию (до 500 за раз)
|
||||||
|
"range-more": (d) => {
|
||||||
|
const a = S.page.items.find((x) => x.status === "free_range" && x.address === d.id);
|
||||||
|
if (!a) return;
|
||||||
|
(S.freeLoaded ||= {})[d.id] = Math.min(Number(d.loaded) + 500, a.range_count);
|
||||||
|
draw();
|
||||||
|
},
|
||||||
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
|
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
|
||||||
"jr-refresh": () => draw(),
|
"jr-refresh": () => draw(),
|
||||||
"jr-settings": () => journalSettingsDialog(),
|
"jr-settings": () => journalSettingsDialog(),
|
||||||
@@ -1167,7 +1211,7 @@ document.addEventListener("input", (e) => {
|
|||||||
if (e.target.id === "clear-pw") { $("#clear-go").disabled = !e.target.value; return; }
|
if (e.target.id === "clear-pw") { $("#clear-go").disabled = !e.target.value; return; }
|
||||||
if (e.target.dataset.input !== "q") return;
|
if (e.target.dataset.input !== "q") return;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => { S.q = e.target.value; S.limit = 100; draw(); }, 250);
|
timer = setTimeout(() => { S.q = e.target.value; S.limit = 100; S.freeOpen = new Set(); S.freeLoaded = {}; draw(); }, 250); // изменение 042: свёрнутые диапазоны сбрасываются с поиском
|
||||||
});
|
});
|
||||||
document.addEventListener("change", (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.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }
|
||||||
|
|||||||
Reference in new issue
Block a user