Задача 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` |
|
||||
| Справочники | `/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)
|
||||
|
||||
+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)
|
||||
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()
|
||||
|
||||
+5
-1
@@ -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):
|
||||
|
||||
@@ -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"})
|
||||
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=<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"
|
||||
parent = client.get(f"/prefixes/{parent['id']}").json()
|
||||
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;
|
||||
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]) : `<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="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>`;
|
||||
}).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 shown = page.items.length;
|
||||
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
|
||||
@@ -610,7 +640,8 @@ function addressDialog(a, presetIp = "") {
|
||||
};
|
||||
const ipField = edit
|
||||
? `<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({
|
||||
title: edit ? "Редактировать адрес" : "Назначить адрес",
|
||||
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 === "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; }
|
||||
|
||||
Reference in new issue
Block a user