Задача 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:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 19:40:22 +03:00
1 parent 9301069c29
commit d1a2efd683
7 files changed
+176 -12

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+52
View File
@@ -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` ответ идентичен прежнему.
+21
View File
@@ -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 — проверка пользователя.
+10
View File
@@ -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
View File
@@ -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; }