diff --git a/README.md b/README.md index 9280413..2dd12ca 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ docs/changes/ планы и итоги доработок docs/reviews/ | Вход | `POST /auth/login`, `POST /auth/login/2fa` (изменение 037), `GET /auth/me` | | Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` | | Префиксы | `/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) | | Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` | | Сводка | `GET /overview` | @@ -157,6 +157,7 @@ docs/changes/ планы и итоги доработок docs/reviews/ - В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037). - Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера. - «Устройства»: колонка «Статус» (бейдж) и поле «Статус» в диалоге добавления/редактирования; «Адреса» подсети: колонки «Тип устройства», «Устройство», «Статус устройства» после «Описание» (изменение 039). +- «Адреса» подсети: подряд идущие свободные адреса свёрнуты в одну строку-диапазон («10.0.0.5 – 10.0.0.20 · Свободно: 16»); клик раскрывает её в отдельные адреса с действием «Назначить адрес», повторный клик сворачивает. Диапазоны длиннее 500 адресов подгружаются порциями по клику «ещё N — показать» (изменение 042). - Переключатель организации — только у `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) | | 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) | +| 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) diff --git a/app/api/v1/prefixes.py b/app/api/v1/prefixes.py index e2fa441..fd50429 100644 --- a/app/api/v1/prefixes.py +++ b/app/api/v1/prefixes.py @@ -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) 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 ): """status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей). - Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети).""" + Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети). + group_free (изменение 042): только в смешанном режиме — подряд идущие свободные адреса сворачиваются + в строки free_range; без параметра поведение и ответ побайтно прежние (совместимость, тесты).""" p = get_or_404(db, Prefix, id, "Префикс") require_org(user, p.organization_id, "Префикс") # изменение 032 cap = capacity(str(p.prefix)) @@ -404,6 +406,33 @@ def list_addresses( 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] + 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": 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: 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)) + if group_free: # изменение 042 + rows = group_free_rows(rows) return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary) total = count(db, select(Address.id).where(*flt)) page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all() diff --git a/app/schemas.py b/app/schemas.py index 7312f73..1c7ae5c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -386,7 +386,7 @@ class AddressOut(BaseModel): id: int | None prefix_id: int address: str - status: str # assigned | reserved | deprecated | free + status: str # assigned | reserved | deprecated | free | free_range (изменение 042: свёрнутый диапазон свободных адресов) dns_name: str = "" description: str = "" device_id: int | None = None @@ -395,6 +395,10 @@ class AddressOut(BaseModel): device_status: str | None = None # изменение 039 note: str = "" 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): diff --git a/docs/changes/042-free-ranges/PLAN.md b/docs/changes/042-free-ranges/PLAN.md new file mode 100644 index 0000000..d41d7a1 --- /dev/null +++ b/docs/changes/042-free-ranges/PLAN.md @@ -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=&limit=`: + `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=&limit=` возвращает ровно адреса этого диапазона. + +## Документация +- `README.md`: «Адреса» и «Интерфейс» — свёртка свободных; строка 042 в истории. +- `SUMMARY.md` — по завершении. + +## Исполнение +По принятой схеме: код и тест пишет агент на Sonnet, он же пересобирает стенд с `--force-recreate`. Моя часть — ревью и `pytest`. +UI проверяет пользователь. + +## Проверка +- `pytest -q` — все зелёные. +- API: `/24` с парой назначенных адресов → 3–5 строк вместо ~254; `free_offset` и раскрытие дают ровно адреса диапазона; IPv6 `/120` — тоже. +- Без `group_free` ответ идентичен прежнему. diff --git a/docs/changes/042-free-ranges/SUMMARY.md b/docs/changes/042-free-ranges/SUMMARY.md new file mode 100644 index 0000000..06334dd --- /dev/null +++ b/docs/changes/042-free-ranges/SUMMARY.md @@ -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 — проверка пользователя. diff --git a/tests/test_api.py b/tests/test_api.py index 5ff5394..ef2f8d1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"}) page = client.get(f"/prefixes/{leaf['id']}/addresses").json() assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6} + + # изменение 042: подряд идущие свободные адреса (.3–.6) сворачиваются в одну строку free_range; + # раскрытие — тот же GET .../addresses?status=free&offset=&limit= + 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" parent = client.get(f"/prefixes/{parent['id']}").json() assert parent["capacity"] == 65534 and parent["used"] == 2 # ёмкость родителя — размер его подсети, used — по поддереву (изменение 025) diff --git a/web/app.js b/web/app.js index 4af3b86..2a4cc3d 100644 --- a/web/app.js +++ b/web/app.js @@ -561,30 +561,60 @@ screens.address = async (id) => { if (store.orgId !== p.organization_id) store.orgId = p.organization_id; const limit = S.limit || 100; 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), api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }), 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; + // изменение 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 chain = []; for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x); const sm = page.summary; // изменение 039: после «Описание» — «Тип устройства», «Устройство», «Статус устройства»; «Описание» и адресные колонки сужены, «Изменён» без изменений const cols = selCols("minmax(120px,0.9fr) minmax(110px,0.8fr) minmax(110px,1fr) 120px minmax(130px,1fr) 140px 110px 160px 44px"); - setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи - const rows = page.items.map((a, n) => { + setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» и «диапазона» нет записи + // изменение 042: обычная строка адреса (в т.ч. раскрытые адреса диапазона, indent — с отступом, как вложенные) + const addrRow = (a, indent = false) => { 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" }]; const devStatus = a.device_status ? badge(...DEVICE_STATUS[a.device_status]) : `—`; // изменение 039 - return `
${free ? selBlank() : selCell(a.id)}${esc(a.address)} + return `
${free ? selBlank() : selCell(a.id)}${esc(a.address)} ${esc(a.dns_name || "—")}${esc(a.description || (free ? "—" : ""))} ${esc(a.device_type_name || "—")}${esc(a.device_name || "—")}${devStatus}${badge(c, t)} ${a.updated_at ? fmtDate(a.updated_at) : "—"}${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}
`; - }).join(""); + }; + // изменение 042: свёрнутая строка диапазона свободных адресов — шеврон, «<начало> – <конец>», бейдж «Свободно: N», без действий и без чекбокса + const rangeRow = (a, open) => `
${selBlank()}${I.chevR(14)}${esc(a.address)} – ${esc(a.range_end)} +—————${badge("", `Свободно: ${a.range_count}`)} +—
`; + // изменение 042: диапазон больше 500 — после подгруженной порции строка «ещё N — показать» + const rangeMoreRow = (a, loaded) => ``; + 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 shown = page.items.length; const more = page.total > shown ? ` · Показать ещё 100` : ""; @@ -610,7 +640,8 @@ function addressDialog(a, presetIp = "") { }; const ipField = edit ? `` - : 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({ title: edit ? "Редактировать адрес" : "Назначить адрес", body: formBody(`
Подсеть: ${esc(p.prefix)} · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}
@@ -1036,7 +1067,7 @@ const actions = { else if (id === "status") S.status = value; else if (id === "family") S.family = 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 === "jactor") { S.jactor = 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)), "adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))), "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-refresh": () => draw(), "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.dataset.input !== "q") return; 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) => { if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }