From a846d3087294c61d11c4a71d5f0370ea2e7ac780 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 20 Sep 2026 12:27:47 +0300 Subject: [PATCH] =?UTF-8?q?IPAM=20Manager:=20API,=20UI-=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B0,=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 --- .claude/CLAUDE.md | 30 + .dockerignore | 8 + .env.example | 11 + .gitignore | 5 + Dockerfile | 10 + README.md | 68 ++ alembic.ini | 17 + alembic/env.py | 20 + alembic/script.py.mako | 22 + alembic/versions/0001_initial_schema.py | 163 ++++ alembic/versions/0002_vrf_org_integrity.py | 36 + .../0003_journal_search_and_rotation.py | 56 ++ .../versions/0004_audit_client_ip_and_meta.py | 25 + app/__init__.py | 0 app/api/__init__.py | 0 app/api/v1/__init__.py | 0 app/api/v1/auth.py | 28 + app/api/v1/journal.py | 168 ++++ app/api/v1/overview.py | 45 + app/api/v1/prefixes.py | 329 +++++++ app/api/v1/refs.py | 298 +++++++ app/config.py | 15 + app/db.py | 12 + app/main.py | 81 ++ app/models.py | 169 ++++ app/request_context.py | 74 ++ app/rotation.py | 74 ++ app/schemas.py | 301 +++++++ app/security.py | 53 ++ app/services.py | 107 +++ docker-compose.yml | 26 + docs/changes/001-ipam-backend/PLAN.md | 96 ++ docs/changes/001-ipam-backend/SUMMARY.md | 28 + docs/changes/002-prefix-vrf-change/PLAN.md | 51 ++ docs/changes/002-prefix-vrf-change/SUMMARY.md | 31 + .../003-journal-search-rotation/PLAN.md | 74 ++ .../003-journal-search-rotation/SUMMARY.md | 27 + docs/changes/004-audit-client-ip/PLAN.md | 66 ++ docs/changes/004-audit-client-ip/SUMMARY.md | 21 + docs/changes/005-clickable-rows/PLAN.md | 49 ++ docs/changes/005-clickable-rows/SUMMARY.md | 23 + requirements-dev.txt | 3 + requirements.txt | 9 + scripts/gen_env.py | 20 + scripts/seed_demo.py | 103 +++ tests/__init__.py | 0 tests/conftest.py | 44 + tests/test_api.py | 82 ++ tests/test_journal.py | 79 ++ web/app.js | 833 ++++++++++++++++++ web/fonts/OFL.txt | 93 ++ web/fonts/fonts.css | 11 + web/fonts/ibm-plex-mono-cyrillic-400.woff2 | Bin 0 -> 8356 bytes web/fonts/ibm-plex-mono-cyrillic-500.woff2 | Bin 0 -> 8460 bytes web/fonts/ibm-plex-mono-latin-400.woff2 | Bin 0 -> 14708 bytes web/fonts/ibm-plex-mono-latin-500.woff2 | Bin 0 -> 14888 bytes web/fonts/ibm-plex-sans-cyrillic-400.woff2 | Bin 0 -> 14800 bytes web/fonts/ibm-plex-sans-cyrillic-500.woff2 | Bin 0 -> 15000 bytes web/fonts/ibm-plex-sans-cyrillic-600.woff2 | Bin 0 -> 15040 bytes web/fonts/ibm-plex-sans-latin-400.woff2 | Bin 0 -> 22588 bytes web/fonts/ibm-plex-sans-latin-500.woff2 | Bin 0 -> 24184 bytes web/fonts/ibm-plex-sans-latin-600.woff2 | Bin 0 -> 24252 bytes web/index.html | 16 + web/styles.css | 184 ++++ 64 files changed, 4194 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_initial_schema.py create mode 100644 alembic/versions/0002_vrf_org_integrity.py create mode 100644 alembic/versions/0003_journal_search_and_rotation.py create mode 100644 alembic/versions/0004_audit_client_ip_and_meta.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/auth.py create mode 100644 app/api/v1/journal.py create mode 100644 app/api/v1/overview.py create mode 100644 app/api/v1/prefixes.py create mode 100644 app/api/v1/refs.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/request_context.py create mode 100644 app/rotation.py create mode 100644 app/schemas.py create mode 100644 app/security.py create mode 100644 app/services.py create mode 100644 docker-compose.yml create mode 100644 docs/changes/001-ipam-backend/PLAN.md create mode 100644 docs/changes/001-ipam-backend/SUMMARY.md create mode 100644 docs/changes/002-prefix-vrf-change/PLAN.md create mode 100644 docs/changes/002-prefix-vrf-change/SUMMARY.md create mode 100644 docs/changes/003-journal-search-rotation/PLAN.md create mode 100644 docs/changes/003-journal-search-rotation/SUMMARY.md create mode 100644 docs/changes/004-audit-client-ip/PLAN.md create mode 100644 docs/changes/004-audit-client-ip/SUMMARY.md create mode 100644 docs/changes/005-clickable-rows/PLAN.md create mode 100644 docs/changes/005-clickable-rows/SUMMARY.md create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 scripts/gen_env.py create mode 100644 scripts/seed_demo.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_journal.py create mode 100644 web/app.js create mode 100644 web/fonts/OFL.txt create mode 100644 web/fonts/fonts.css create mode 100644 web/fonts/ibm-plex-mono-cyrillic-400.woff2 create mode 100644 web/fonts/ibm-plex-mono-cyrillic-500.woff2 create mode 100644 web/fonts/ibm-plex-mono-latin-400.woff2 create mode 100644 web/fonts/ibm-plex-mono-latin-500.woff2 create mode 100644 web/fonts/ibm-plex-sans-cyrillic-400.woff2 create mode 100644 web/fonts/ibm-plex-sans-cyrillic-500.woff2 create mode 100644 web/fonts/ibm-plex-sans-cyrillic-600.woff2 create mode 100644 web/fonts/ibm-plex-sans-latin-400.woff2 create mode 100644 web/fonts/ibm-plex-sans-latin-500.woff2 create mode 100644 web/fonts/ibm-plex-sans-latin-600.woff2 create mode 100644 web/index.html create mode 100644 web/styles.css diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..be2e2e2 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Твоя роль + +- DevOps инженер +- Разработчик Backend +- Архитектор информационных систем +- Архитектор корпоративной сети + +# Стиль общения + +- профессиональный, но без жаргона + +# Стиль ответов + +- максимально емкие и содержательные +- не проваливайся в лишние детали, если это явно не было запрошено + +# Создание артефактов + +- На каждое новое изменение должен быть артефакт в .md файле +- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле +- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле +- каждое изменение дополняет или обновляет README.md + +# Автотесты + +- минимальное количество тестов + +# Окружение для разработки + +- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27e64dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +venv/ +.env +.git +__pycache__/ +*.pyc +tests/ +.pytest_cache/ +docs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b08e9f --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# python scripts/gen_env.py создаёт .env со случайными значениями +POSTGRES_DB=ipam +POSTGRES_USER=ipam +POSTGRES_PASSWORD=change-me +JWT_SECRET=change-me +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +APP_PORT=8088 +DB_HOST_PORT=55432 +# CIDR доверенных reverse-proxy через запятую (у них берётся X-Forwarded-For); по умолчанию пусто — IP клиента = адрес сокета +TRUSTED_PROXIES= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c61e7ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +.env +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d588685 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +WORKDIR /srv +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/srv +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY alembic.ini . +COPY alembic alembic +COPY app app +COPY web web +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..17d24fc --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# IPAM Manager + +Реестр IP-адресов и адресных префиксов в разрезе организаций. API-first: backend на Python (FastAPI) с PostgreSQL, +UI-админка — отдельный лёгкий SPA (`web/`, без сборки), который только визуализирует ответы API. +Макеты: страница «IPAM Manager» дизайн-канваса ros_control. + +## Быстрый старт +```bash +python3 scripts/gen_env.py # .env со случайными паролями/секретами (в .gitignore) +docker compose up -d --build # postgres + app; миграции применяются автоматически +python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt +venv/bin/python scripts/seed_demo.py # (по желанию) демо-данные из макетов +``` +- UI: http://127.0.0.1:8088/ · Swagger: http://127.0.0.1:8088/docs (порт приложения — `APP_PORT` в `.env`, слушает 0.0.0.0; порт БД — только 127.0.0.1) +- Логин/пароль администратора — `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env` (создаётся при первом старте). + +## Архитектура +| Слой | Технологии | +|---|---| +| API | FastAPI, pydantic v2, JWT (argon2), роли `admin` (запись) / `viewer` (чтение) | +| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (`alembic/versions`), типы `CIDR`/`INET` | +| UI | статический SPA (ES-модуль, vanilla JS), раздаётся приложением; шрифты IBM Plex — локально в `web/fonts/` (woff2 latin+cyrillic, лицензия OFL), внешних зависимостей нет | + +``` +app/ main.py config.py db.py security.py models.py schemas.py services.py api/v1/{auth,refs,prefixes,overview}.py +web/ index.html styles.css app.js +alembic/ scripts/{gen_env,seed_demo}.py tests/ docs/changes/ +``` + +## Модель данных +`organizations` → `vrfs` (по организации, «default» создаётся автоматически) → `prefixes` (дерево через `parent_id`, +вложенность определяется автоматически) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`. +- Ёмкость листового префикса — размер подсети (IPv4 без сетевого/broadcast); родителя — сумма вложенных листьев. +- «Свободные» адреса не хранятся, а вычисляются; в списке адресов они показываются для подсетей до /20. +- Обзор считает использование только по IPv4. +- VRF — часть адресного плана организации: имя уникально **в пределах организации** (без учёта регистра), в разных организациях + имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД. +- Смена VRF у префикса (`PATCH /prefixes/{id}` с `vrf_id`) — только среди VRF той же организации; переносится префикс вместе с вложенными, + дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422. +- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). + +## API (`/api/v1`) +`POST /auth/login` · `GET /auth/me` · `GET /overview` +CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}` +Адреса префикса: `GET|POST /prefixes/{id}/addresses` (`status`, `q`, `limit`, `offset`), `POST …/addresses/next` — автоназначение из пула. +Ошибки: `{code, message, fields}` (для блокировок/лимитов — дополнительные поля `attempts_left`, `retry_after_seconds`). Каждое изменение пишется в `audit_log`. + +### Журнал +- `GET /audit` — поиск и фильтры: `q` (сообщение, метка объекта, начало ID записи), `event_type` (`prefix.created`), `entity_type`, `actor` (`ui:admin`, `system`, `anonymous`), `date_from`/`date_to` (UTC), `limit`/`offset`; `GET /audit/summary`, `/audit/facets`, `/audit/{uid}`. +- `GET|PUT /journal/settings` — ротация: `retention_days` (по умолчанию 90) и `max_entries` (100 000), `0` — без ограничения. Ротация идёт раз в час и сразу при сохранении настроек + (advisory-lock защищает от параллельного запуска); каждая ротация с удалениями фиксируется записью `journal.rotated`. Запись — только admin. +- `POST /journal/clear {password}` — очистка с подтверждением пароля текущего пользователя (admin); 5 неверных попыток за 10 минут → блокировка на 10 минут (429). В журнале остаётся запись `journal.cleared`. +- **IP и метаданные запроса:** каждая запись, созданная в рамках HTTP-запроса, хранит `client_ip` и `meta` (`user_agent`, `method`, `path`, `request_id`; ответ содержит `X-Request-ID`); + системные события (ротация) — без IP. Фильтр `GET /audit?client_ip=` принимает IP или подсеть (`192.168.5.0/24`), текстовый поиск `q` ищет и по началу IP. + IP берётся из адреса сокета. `X-Forwarded-For` учитывается только от прокси из `TRUSTED_PROXIES` (CIDR через запятую в `.env`, по умолчанию пусто) — иначе IP можно подделать. + Запросы с самой машины через `127.0.0.1` Docker показывает адресом шлюза сети (`172.x.0.1`); с LAN-адреса и удалённых хостов виден реальный источник. +- В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`) и служебные события (`journal.*`, актор `system`). + +## Поведение таблиц UI +Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации; +«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки; +Ctrl/Shift+клик и выделение текста тоже игнорируются. + +## Тесты +Идут против приложения в контейнерах, учётные данные берутся из `.env`: +```bash +docker compose up -d --build && venv/bin/python -m pytest -q +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e897ea1 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,17 @@ +[alembic] +script_location = alembic +[loggers] +keys = root +[handlers] +keys = console +[formatters] +keys = generic +[logger_root] +level = WARN +handlers = console +[handler_console] +class = StreamHandler +args = (sys.stderr,) +formatter = generic +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..89c6a7e --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,20 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import settings +from app.models import Base + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) +target_metadata = Base.metadata + + +def run(): + engine = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool) + with engine.connect() as conn: + context.configure(connection=conn, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +run() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..0632648 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = None +depends_on = None + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..7ed96d7 --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -0,0 +1,163 @@ +"""initial schema + +Revision ID: 0001 +Revises: +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects import postgresql + +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_log', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('ts', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('entity_type', sa.String(length=50), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=True), + sa.Column('entity_label', sa.String(length=255), nullable=False), + sa.Column('action', sa.String(length=20), nullable=False), + sa.Column('diff', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False) + op.create_index(op.f('ix_audit_log_ts'), 'audit_log', ['ts'], unique=False) + op.create_table('device_types', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('organizations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('short_name', sa.String(length=100), nullable=False), + sa.Column('inn', sa.String(length=12), nullable=False), + sa.Column('address', sa.String(length=500), nullable=False), + sa.Column('contact_person', sa.String(length=255), nullable=False), + sa.Column('phone', sa.String(length=50), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('inn'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.Enum('admin', 'viewer', name='user_role'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('devices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('device_type_id', sa.Integer(), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=False), + sa.Column('mac', sa.String(length=17), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['device_type_id'], ['device_types.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('organization_id', 'name') + ) + op.create_index(op.f('ix_devices_organization_id'), 'devices', ['organization_id'], unique=False) + op.create_table('isps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=False), + sa.Column('contract_number', sa.String(length=100), nullable=False), + sa.Column('hotline', sa.String(length=50), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_isps_organization_id'), 'isps', ['organization_id'], unique=False) + op.create_table('vrfs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('route_target', sa.String(length=50), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('organization_id', 'name') + ) + op.create_table('isp_networks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('isp_id', sa.Integer(), nullable=False), + sa.Column('cidr', postgresql.CIDR(), nullable=False), + sa.ForeignKeyConstraint(['isp_id'], ['isps.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_isp_networks_isp_id'), 'isp_networks', ['isp_id'], unique=False) + op.create_table('prefixes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=False), + sa.Column('vrf_id', sa.Integer(), nullable=False), + sa.Column('prefix', postgresql.CIDR(), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('status', sa.Enum('active', 'reserved', 'deprecated', name='prefix_status'), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('is_pool', sa.Boolean(), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['parent_id'], ['prefixes.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['vrf_id'], ['vrfs.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('vrf_id', 'prefix') + ) + op.create_index(op.f('ix_prefixes_organization_id'), 'prefixes', ['organization_id'], unique=False) + op.create_index(op.f('ix_prefixes_vrf_id'), 'prefixes', ['vrf_id'], unique=False) + op.create_table('addresses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('prefix_id', sa.Integer(), nullable=False), + sa.Column('address', postgresql.INET(), nullable=False), + sa.Column('status', sa.Enum('assigned', 'reserved', 'deprecated', name='address_status'), nullable=False), + sa.Column('dns_name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('device_id', sa.Integer(), nullable=True), + sa.Column('note', sa.Text(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['prefix_id'], ['prefixes.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('prefix_id', 'address') + ) + op.create_index(op.f('ix_addresses_device_id'), 'addresses', ['device_id'], unique=False) + op.create_index(op.f('ix_addresses_prefix_id'), 'addresses', ['prefix_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_addresses_prefix_id'), table_name='addresses') + op.drop_index(op.f('ix_addresses_device_id'), table_name='addresses') + op.drop_table('addresses') + op.drop_index(op.f('ix_prefixes_vrf_id'), table_name='prefixes') + op.drop_index(op.f('ix_prefixes_organization_id'), table_name='prefixes') + op.drop_table('prefixes') + op.drop_index(op.f('ix_isp_networks_isp_id'), table_name='isp_networks') + op.drop_table('isp_networks') + op.drop_table('vrfs') + op.drop_index(op.f('ix_isps_organization_id'), table_name='isps') + op.drop_table('isps') + op.drop_index(op.f('ix_devices_organization_id'), table_name='devices') + op.drop_table('devices') + op.drop_table('users') + op.drop_table('organizations') + op.drop_table('device_types') + op.drop_index(op.f('ix_audit_log_ts'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log') + op.drop_table('audit_log') + # ### end Alembic commands ### diff --git a/alembic/versions/0002_vrf_org_integrity.py b/alembic/versions/0002_vrf_org_integrity.py new file mode 100644 index 0000000..00c88bb --- /dev/null +++ b/alembic/versions/0002_vrf_org_integrity.py @@ -0,0 +1,36 @@ +"""VRF принадлежит организации префикса (составной FK); имя VRF уникально в организации без учёта регистра + +Revision ID: 0002 +Revises: 0001 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + dup = bind.execute(sa.text( + "SELECT organization_id, lower(name) FROM vrfs GROUP BY 1, 2 HAVING count(*) > 1")).all() + if dup: + raise RuntimeError(f"Есть VRF с совпадающими (без учёта регистра) именами в одной организации: {dup}") + bad = bind.execute(sa.text( + "SELECT p.id FROM prefixes p JOIN vrfs v ON v.id = p.vrf_id WHERE v.organization_id <> p.organization_id")).all() + if bad: + raise RuntimeError(f"Префиксы с VRF чужой организации: {[r[0] for r in bad]}") + + op.create_unique_constraint("uq_vrfs_id_organization_id", "vrfs", ["id", "organization_id"]) + op.create_foreign_key("fk_prefixes_vrf_org", "prefixes", "vrfs", ["vrf_id", "organization_id"], ["id", "organization_id"]) + op.drop_constraint("vrfs_organization_id_name_key", "vrfs", type_="unique") + op.create_index("uq_vrfs_org_lower_name", "vrfs", ["organization_id", sa.text("lower(name)")], unique=True) + + +def downgrade() -> None: + op.drop_index("uq_vrfs_org_lower_name", table_name="vrfs") + op.create_unique_constraint("vrfs_organization_id_name_key", "vrfs", ["organization_id", "name"]) + op.drop_constraint("fk_prefixes_vrf_org", "prefixes", type_="foreignkey") + op.drop_constraint("uq_vrfs_id_organization_id", "vrfs", type_="unique") diff --git a/alembic/versions/0003_journal_search_and_rotation.py b/alembic/versions/0003_journal_search_and_rotation.py new file mode 100644 index 0000000..3022304 --- /dev/null +++ b/alembic/versions/0003_journal_search_and_rotation.py @@ -0,0 +1,56 @@ +"""journal search and rotation + +Revision ID: 0003 +Revises: 0002 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0003' +down_revision = '0002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table('app_settings', + sa.Column('key', sa.String(length=50), nullable=False), + sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('clear_attempts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('ts', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_clear_attempts_user_id'), 'clear_attempts', ['user_id'], unique=False) + op.add_column('audit_log', sa.Column('uid', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False)) + op.add_column('audit_log', sa.Column('message', sa.Text(), server_default='', nullable=False)) + op.create_index('ix_audit_log_entity_type_action', 'audit_log', ['entity_type', 'action'], unique=False) + op.create_unique_constraint("audit_log_uid_key", "audit_log", ["uid"]) + + # бэкфилл человекочитаемого сообщения для существующих записей (с согласованием рода глагола) + nouns = {"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"), + "device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m")} + verbs = {"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"), + "deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено")} + for entity, (noun, gender) in nouns.items(): + for action, forms in verbs.items(): + verb = forms[{"m": 0, "f": 1, "n": 2}[gender]] + op.get_bind().execute(sa.text( + "UPDATE audit_log SET message = :noun || ' ' || entity_label || ' ' || :verb WHERE entity_type = :e AND action = :a"), + {"noun": noun, "verb": verb, "e": entity, "a": action}) + op.execute("""INSERT INTO app_settings (key, value) VALUES ('journal', '{"retention_days": 90, "max_entries": 100000}')""") + + +def downgrade() -> None: + op.drop_constraint("audit_log_uid_key", "audit_log", type_="unique") + op.drop_index('ix_audit_log_entity_type_action', table_name='audit_log') + op.drop_column('audit_log', 'message') + op.drop_column('audit_log', 'uid') + op.drop_index(op.f('ix_clear_attempts_user_id'), table_name='clear_attempts') + op.drop_table('clear_attempts') + op.drop_table('app_settings') diff --git a/alembic/versions/0004_audit_client_ip_and_meta.py b/alembic/versions/0004_audit_client_ip_and_meta.py new file mode 100644 index 0000000..e0e1da2 --- /dev/null +++ b/alembic/versions/0004_audit_client_ip_and_meta.py @@ -0,0 +1,25 @@ +"""audit client ip and meta + +Revision ID: 0004 +Revises: 0003 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0004' +down_revision = '0003' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('audit_log', sa.Column('client_ip', postgresql.INET(), nullable=True)) + op.add_column('audit_log', sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.create_index(op.f('ix_audit_log_client_ip'), 'audit_log', ['client_ip'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_audit_log_client_ip'), table_name='audit_log') + op.drop_column('audit_log', 'meta') + op.drop_column('audit_log', 'client_ip') diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..63ae53c --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db import get_db +from app.models import User +from app.schemas import LoginIn, TokenOut, UserOut +from app.security import create_token, current_user, verify_password +from app.services import ANONYMOUS, audit + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenOut) +def login(body: LoginIn, db: Session = Depends(get_db)): + user = db.scalar(select(User).where(User.username == body.username, User.is_active)) + if user is None or not verify_password(body.password, user.password_hash): + audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI") + db.commit() + raise HTTPException(401, "Неверный логин или пароль") + audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}") + db.commit() + return TokenOut(access_token=create_token(user)) + + +@router.get("/me", response_model=UserOut) +def me(user: User = Depends(current_user)): + return user diff --git a/app/api/v1/journal.py b/app/api/v1/journal.py new file mode 100644 index 0000000..28f3492 --- /dev/null +++ b/app/api/v1/journal.py @@ -0,0 +1,168 @@ +"""Журнал: поиск и фильтры, запись, настройки ротации, очистка с подтверждением пароля.""" +import ipaddress +import uuid +from datetime import date, datetime, time, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import String, cast, delete, func, or_, select +from sqlalchemy.orm import Session + +from app import schemas as s +from app.db import get_db +from app.models import AuditLog, ClearAttempt, User +from app.rotation import get_settings, rotate, save_settings +from app.security import admin_user, current_user, verify_password +from app.services import audit, commit + +router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"]) +MAX_ATTEMPTS = 5 +LOCK_MINUTES = 10 + + +def _escape_like(q: str) -> str: + return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: date | None, date_to: date | None, q: str, client_ip: str = ""): + if event_type: + et, _, action = event_type.partition(".") + stmt = stmt.where(AuditLog.entity_type == et, AuditLog.action == action) + if entity_type: + stmt = stmt.where(AuditLog.entity_type == entity_type) + if actor: + stmt = stmt.where(AuditLog.username == actor.removeprefix("ui:")) + if client_ip.strip(): + try: + net = ipaddress.ip_network(client_ip.strip(), strict=False) # точный IP или подсеть + except ValueError: + raise HTTPException(422, "Некорректный IP-адрес или подсеть") + stmt = stmt.where(AuditLog.client_ip.op("<<=")(str(net))) + if date_from: + stmt = stmt.where(AuditLog.ts >= datetime.combine(date_from, time.min, timezone.utc)) + if date_to: + stmt = stmt.where(AuditLog.ts < datetime.combine(date_to + timedelta(days=1), time.min, timezone.utc)) + if q.strip(): + term = _escape_like(q.strip()) + like = f"%{term}%" + uid_prefix = _escape_like(q.strip().removeprefix("evt_").lower()) + "%" + stmt = stmt.where(or_( + AuditLog.message.ilike(like, escape="\\"), AuditLog.entity_label.ilike(like, escape="\\"), + cast(AuditLog.uid, String).ilike(uid_prefix, escape="\\"), + func.host(AuditLog.client_ip).ilike(f"{term}%", escape="\\"), + )) + return stmt + + +@router.get("/audit", response_model=s.Page[s.AuditOut]) +def list_audit( + event_type: str = "", entity_type: str = "", actor: str = "", date_from: date | None = None, date_to: date | None = None, + q: str = "", client_ip: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db), +): + stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + rows = db.scalars(stmt.order_by(AuditLog.id.desc()).limit(limit).offset(offset)).all() + return s.Page(items=[s.AuditOut.from_row(r) for r in rows], total=total) + + +class Summary(BaseModel): + total: int + oldest_ts: datetime | None + retention_days: int + max_entries: int + + +@router.get("/audit/summary", response_model=Summary) +def summary(db: Session = Depends(get_db)): + cfg = get_settings(db) + total, oldest = db.execute(select(func.count(), func.min(AuditLog.ts))).one() + return Summary(total=total, oldest_ts=oldest, **cfg) + + +class Facets(BaseModel): + event_types: list[str] + entity_types: list[str] + actors: list[str] + + +@router.get("/audit/facets", response_model=Facets) +def facets(db: Session = Depends(get_db)): + pairs = db.execute(select(AuditLog.entity_type, AuditLog.action).distinct().order_by(AuditLog.entity_type, AuditLog.action)).all() + users = db.scalars(select(AuditLog.username).distinct().order_by(AuditLog.username)).all() + return Facets( + event_types=[f"{e}.{a}" for e, a in pairs], entity_types=sorted({e for e, _ in pairs}), + actors=[u if u in ("system", "anonymous") else f"ui:{u}" for u in users], + ) + + +@router.get("/audit/{uid}", response_model=s.AuditOut) +def get_entry(uid: uuid.UUID, db: Session = Depends(get_db)): + row = db.scalar(select(AuditLog).where(AuditLog.uid == uid)) + if row is None: + raise HTTPException(404, "Запись не найдена") + return s.AuditOut.from_row(row) + + +# ------------------------------------------------------------------- настройки +class JournalSettings(BaseModel): + retention_days: int = Field(ge=0, le=3650) + max_entries: int = Field(ge=0, le=10_000_000) + + +@router.get("/journal/settings", response_model=JournalSettings) +def read_settings(db: Session = Depends(get_db)): + return get_settings(db) + + +class SettingsSaved(JournalSettings): + deleted: int + + +@router.put("/journal/settings", response_model=SettingsSaved) +def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(admin_user)): + old = get_settings(db) + new = body.model_dump() + save_settings(db, new) + changed = {k: f"{old[k]} → {v}" for k, v in new.items() if old[k] != v} + audit(db, user, "journal", None, "settings_updated", "settings", changed or None, + message="Настройки журнала: " + ("; ".join(f"{k} {v}" for k, v in changed.items()) or "без изменений")) + commit(db) + result = rotate(db) or {"by_age": 0, "by_count": 0} # ротация сразу при сохранении + return SettingsSaved(**new, deleted=result["by_age"] + result["by_count"]) + + +# --------------------------------------------------------------------- очистка +class ClearIn(BaseModel): + password: str + + +def _lock_state(db: Session, user_id: int) -> tuple[int, int]: + """(неудачных за окно, секунд до конца блокировки). Блокировка — LOCK_MINUTES после 5-й неудачи в пределах окна.""" + now = datetime.now(timezone.utc) + last = db.scalars(select(ClearAttempt.ts).where(ClearAttempt.user_id == user_id).order_by(ClearAttempt.id.desc()).limit(MAX_ATTEMPTS)).all() + window = timedelta(minutes=LOCK_MINUTES) + in_window = sum(1 for ts in last if now - ts <= window) + if len(last) == MAX_ATTEMPTS and last[0] - last[-1] <= window: + remaining = (last[0] + window - now).total_seconds() + if remaining > 0: + return MAX_ATTEMPTS, int(remaining) + 1 + return in_window, 0 + + +@router.post("/journal/clear") +def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + used, retry = _lock_state(db, user.id) + if retry: + raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry}) + if not verify_password(body.password, user.password_hash): + db.add(ClearAttempt(user_id=user.id)) + db.commit() + used, retry = _lock_state(db, user.id) + if retry: + raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry}) + raise HTTPException(403, {"message": "Неверный пароль", "attempts_left": MAX_ATTEMPTS - used}) + deleted = db.execute(delete(AuditLog)).rowcount + db.execute(delete(ClearAttempt).where(ClearAttempt.user_id == user.id)) + audit(db, user, "journal", None, "cleared", "clear", {"deleted": deleted}, message=f"Журнал очищен: удалено записей {deleted}") + commit(db) + return {"deleted": deleted} diff --git a/app/api/v1/overview.py b/app/api/v1/overview.py new file mode 100644 index 0000000..500f4cc --- /dev/null +++ b/app/api/v1/overview.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app import schemas as s +from app.api.v1.prefixes import _prefix_outs +from app.db import get_db +from app.models import Address, AddressStatus, AuditLog, Prefix, PrefixStatus, Vrf +from app.security import current_user +from app.services import capacity, utilization + +router = APIRouter(dependencies=[Depends(current_user)]) + + +class Overview(BaseModel): + prefixes: int + vrfs: int + assigned: int + capacity: int + utilization: int + reserved: int + top_prefixes: list[s.PrefixOut] + recent_changes: list[s.AuditOut] + + +@router.get("/overview", response_model=Overview, tags=["overview"]) +def overview(db: Session = Depends(get_db)): + prefixes = db.scalars(select(Prefix).where(Prefix.status == PrefixStatus.active)).all() + outs = _prefix_outs(db, list(prefixes)) + parents = {p.parent_id for p in outs if p.parent_id} + leaves = [p for p in outs if p.id not in parents] # ёмкость считаем по листьям, чтобы не удваивать + leaves4 = [p for p in leaves if p.family == 4] # IPv6-пространство несопоставимо по размеру — в метрики использования не входит + cap = sum(p.capacity for p in leaves4) + v4 = func.family(Address.address) == 4 + assigned = db.scalar(select(func.count()).select_from(Address).where(Address.status == AddressStatus.assigned, v4)) or 0 + reserved = db.scalar(select(func.count()).select_from(Address).where(Address.status == AddressStatus.reserved, v4)) or 0 + top = sorted((p for p in leaves4 if p.capacity > 1), key=lambda p: p.utilization, reverse=True)[:4] + recent = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(4)).all() + return Overview( + prefixes=db.scalar(select(func.count()).select_from(Prefix)) or 0, + vrfs=db.scalar(select(func.count()).select_from(Vrf)) or 0, + assigned=assigned, capacity=cap, utilization=utilization(assigned, cap), reserved=reserved, + top_prefixes=top, recent_changes=[s.AuditOut.from_row(r) for r in recent], + ) diff --git a/app/api/v1/prefixes.py b/app/api/v1/prefixes.py new file mode 100644 index 0000000..2a15561 --- /dev/null +++ b/app/api/v1/prefixes.py @@ -0,0 +1,329 @@ +import ipaddress + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import String, and_, cast, func, or_, select, update +from sqlalchemy.orm import Session + +from app import schemas as s +from app.db import get_db +from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf +from app.security import admin_user, current_user +from app.services import ( + MAX_CAPACITY, apply_update, audit, capacity, commit, count, flush, get_or_404, next_free, utilization, +) + +router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"]) +FREE_LISTING_LIMIT = 4096 # «свободные» строки показываем, только если подсеть не больше /20 + + +# -------------------------------------------------------------------- prefixes +def _usage(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]: + """{prefix_id: (assigned, stored)} с учётом вложенных префиксов того же VRF.""" + if not ids: + return {} + p, c = Prefix.__table__.alias("p"), Prefix.__table__.alias("c") + a = Address.__table__ + rows = db.execute( + select( + p.c.id, + func.count().filter(a.c.status == AddressStatus.assigned), + func.count(a.c.id), + ) + .select_from(p.join(c, and_(c.c.vrf_id == p.c.vrf_id, c.c.prefix.op("<<=")(p.c.prefix))) + .join(a, a.c.prefix_id == c.c.id)) + .where(p.c.id.in_(ids)) + .group_by(p.c.id) + ).all() + return {r[0]: (r[1], r[2]) for r in rows} + + +def _depths(db: Session, organization_id: int) -> dict[int, int]: + parents = dict(db.execute(select(Prefix.id, Prefix.parent_id).where(Prefix.organization_id == organization_id)).all()) + + def depth(i: int) -> int: + d = 0 + while parents.get(i) is not None: + i, d = parents[i], d + 1 + return d + + return {i: depth(i) for i in parents} + + +def _capacities(db: Session, organization_id: int) -> dict[int, int]: + """Ёмкость листа — размер подсети; ёмкость родителя — сумма ёмкостей вложенных листьев.""" + rows = db.execute(select(Prefix.id, Prefix.parent_id, Prefix.prefix).where(Prefix.organization_id == organization_id)).all() + kids: dict[int, list[int]] = {} + for i, parent, _ in rows: + if parent is not None: + kids.setdefault(parent, []).append(i) + own = {i: capacity(str(cidr)) for i, _, cidr in rows} + memo: dict[int, int] = {} + + def cap(i: int) -> int: + if i not in memo: + memo[i] = sum(cap(k) for k in kids[i]) if i in kids else own[i] + return memo[i] + + return {i: min(cap(i), MAX_CAPACITY) for i in own} + + +def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]: + usage = _usage(db, [r.id for r in rows]) + depths: dict[int, int] = {} + caps: dict[int, int] = {} + for org_id in {r.organization_id for r in rows}: + depths.update(_depths(db, org_id)) + caps.update(_capacities(db, org_id)) + out = [] + for r in rows: + used, stored = usage.get(r.id, (0, 0)) + cap = caps.get(r.id, capacity(str(r.prefix))) + out.append(s.PrefixOut( + id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=r.vrf.name, + prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version, + description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0), + is_pool=r.is_pool, note=r.note, used=used, capacity=cap, + utilization=utilization(used, cap), addresses_count=stored, + )) + return out + + +def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: frozenset[int] = frozenset()) -> None: + """Вписывает префикс в дерево его VRF: находит самого узкого родителя и забирает под себя + вложенные префиксы, чей текущий родитель шире (exclude — уже подчинённые ему, при переносе поддерева).""" + cidr = str(p.prefix) + if not keep_parent: + p.parent_id = db.scalar( + select(Prefix.id) + .where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op(">>")(cidr)) + .order_by(func.masklen(Prefix.prefix).desc()).limit(1) + ) + plen = ipaddress.ip_network(cidr).prefixlen + inner = db.scalars( + select(Prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(cidr)) + ).all() + for c in inner: + if c.id in exclude: + continue + cur = db.get(Prefix, c.parent_id) if c.parent_id else None + if cur is None or ipaddress.ip_network(str(cur.prefix)).prefixlen < plen: + c.parent_id = p.id + + +def _subtree(db: Session, root: Prefix) -> list[Prefix]: + """Префикс и все вложенные по цепочке parent_id.""" + result, frontier = [root], [root.id] + while frontier: + kids = db.scalars(select(Prefix).where(Prefix.parent_id.in_(frontier))).all() + result += kids + frontier = [k.id for k in kids] + return result + + +def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]: + """Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов).""" + vrf = get_or_404(db, Vrf, vrf_id, "VRF") + if vrf.organization_id != p.organization_id: + raise HTTPException(422, "Целевой VRF принадлежит другой организации") + subtree = _subtree(db, p) + taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all() + if taken: + raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}") + old_name = p.vrf.name + for m in subtree: + m.vrf = vrf + p.parent_id = None + db.flush() + attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree)) + return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree) + + +@router.get("/prefixes", response_model=s.Page[s.PrefixOut]) +def list_prefixes( + organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None, + family: int | None = Query(None, ge=4, le=6), q: str = "", + limit: int = Query(100, le=1000), offset: int = 0, db: Session = Depends(get_db), +): + stmt = select(Prefix) + if organization_id: + stmt = stmt.where(Prefix.organization_id == organization_id) + if vrf_id: + stmt = stmt.where(Prefix.vrf_id == vrf_id) + if status: + stmt = stmt.where(Prefix.status == status) + if family: + stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6)) + if q: + stmt = stmt.where(or_(cast(Prefix.prefix, String).ilike(f"%{q.strip()}%"), Prefix.description.ilike(f"%{q.strip()}%"))) + total = count(db, stmt) + rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all() + return s.Page(items=_prefix_outs(db, list(rows)), total=total) + + +@router.get("/prefixes/{id}", response_model=s.PrefixOut) +def get_prefix(id: int, db: Session = Depends(get_db)): + return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0] + + +@router.post("/prefixes", response_model=s.PrefixOut, status_code=201) +def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + vrf = get_or_404(db, Vrf, body.vrf_id, "VRF") + if vrf.organization_id != body.organization_id: + raise HTTPException(422, "VRF принадлежит другой организации") + get_or_404(db, Organization, body.organization_id, "Организация") + if db.scalar(select(Prefix.id).where(Prefix.vrf_id == vrf.id, Prefix.prefix == body.prefix)): + raise HTTPException(409, "Такой префикс уже есть в этом VRF") + + parent_id = body.parent_id + if parent_id is not None: + parent = get_or_404(db, Prefix, parent_id, "Родительский префикс") + if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))): + raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF") + p = Prefix(**{**body.model_dump(), "parent_id": parent_id}) + db.add(p) + flush(db, "Такой префикс уже есть в этом VRF") + attach_to_tree(db, p, keep_parent=parent_id is not None) + audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name}) + commit(db, "Такой префикс уже есть в этом VRF") + return _prefix_outs(db, [p])[0] + + +@router.patch("/prefixes/{id}", response_model=s.PrefixOut) +def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): + p = get_or_404(db, Prefix, id, "Префикс") + data = body.model_dump(exclude_unset=True, exclude_none=True) + new_vrf = data.pop("vrf_id", None) + changed = {k: str(v) for k, v in apply_update(p, data).items()} + if new_vrf is not None and new_vrf != p.vrf_id: + changed.update(_move_to_vrf(db, p, new_vrf)[0]) + audit(db, user, "prefix", p, "updated", str(p.prefix), changed) + commit(db) + return _prefix_outs(db, [p])[0] + + +@router.delete("/prefixes/{id}", status_code=204) +def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)): + p = get_or_404(db, Prefix, id, "Префикс") + if not force and count(db, select(Address.id).where(Address.prefix_id == id)): + raise HTTPException(409, "В префиксе есть адреса; удалите их или используйте force=true") + db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id)) + audit(db, user, "prefix", p, "deleted", str(p.prefix)) + db.delete(p) + commit(db) + + +# ------------------------------------------------------------------- addresses +def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut: + return s.AddressOut( + id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value, + dns_name=a.dns_name, description=a.description, device_id=a.device_id, device_name=device_name, + note=a.note, updated_at=a.updated_at, + ) + + +def _check_device(db: Session, prefix: Prefix, device_id: int | None): + if device_id is not None: + d = get_or_404(db, Device, device_id, "Устройство") + if d.organization_id != prefix.organization_id: + raise HTTPException(422, "Устройство принадлежит другой организации") + + +@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage) +def list_addresses( + id: int, status: str = "", q: str = "", limit: int = Query(100, le=500), offset: int = 0, + db: Session = Depends(get_db), +): + """status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).""" + p = get_or_404(db, Prefix, id, "Префикс") + cap = capacity(str(p.prefix)) + counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all()) + stored = sum(counts.values()) + summary = s.AddressSummary( + assigned=counts.get(AddressStatus.assigned, 0), reserved=counts.get(AddressStatus.reserved, 0), + deprecated=counts.get(AddressStatus.deprecated, 0), free=max(cap - stored, 0), capacity=cap, + ) + if status and status not in {"free", *(x.value for x in AddressStatus)}: + raise HTTPException(422, "Неизвестный статус") + + stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(Address.prefix_id == id) + if status and status != "free": + stmt = stmt.where(Address.status == AddressStatus(status)) + if q: + like = f"%{q.strip()}%" + stmt = stmt.where(or_(func.host(Address.address).ilike(like), Address.dns_name.ilike(like), Address.description.ilike(like))) + rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] if status != "free" else [] + + net = ipaddress.ip_network(str(p.prefix)) + want_free = status == "free" or (not status and not q and cap <= FREE_LISTING_LIMIT) + if want_free: + used = {ipaddress.ip_address(s.ip_text(a)) for a in db.scalars(select(Address.address).where(Address.prefix_id == id))} + hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net) + need = offset + limit if status == "free" else cap + free = [] + for ip in hosts: + if ip not in used: + free.append(s.AddressOut(id=None, prefix_id=id, address=str(ip), status="free")) + if len(free) >= need: + break + rows = sorted(rows + free, key=lambda r: ipaddress.ip_address(r.address)) + total = summary.free if status == "free" else (len(rows) if want_free or q or status else stored) + return s.AddressPage(items=rows[offset:offset + limit], total=total, summary=summary) + + +@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201) +def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + p = get_or_404(db, Prefix, id, "Префикс") + if ipaddress.ip_address(body.address) not in ipaddress.ip_network(str(p.prefix)): + raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}") + _check_device(db, p, body.device_id) + a = Address(prefix_id=id, **body.model_dump()) + db.add(a) + flush(db, "Адрес уже есть в этом префиксе") + audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address) + commit(db, "Адрес уже есть в этом префиксе") + db.refresh(a) + return _addr_out(a) + + +@router.post("/prefixes/{id}/addresses/next", response_model=s.AddressOut, status_code=201) +def allocate_next( + id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user) +): + """Автоназначение первого свободного адреса; только для префиксов с флагом is_pool.""" + p = get_or_404(db, Prefix, id, "Префикс") + if not p.is_pool: + raise HTTPException(422, "Префикс не является пулом для автоназначения") + ip = next_free(db, id, str(p.prefix)) + if ip is None: + raise HTTPException(409, "В префиксе нет свободных адресов") + data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {} + _check_device(db, p, data.get("device_id")) + a = Address(prefix_id=id, address=ip, **data) + db.add(a) + flush(db, "Адрес уже занят, повторите запрос") + audit(db, user, "address", a, "assigned", ip) + commit(db, "Адрес уже занят, повторите запрос") + db.refresh(a) + return _addr_out(a) + + +@router.patch("/addresses/{id}", response_model=s.AddressOut) +def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): + a = get_or_404(db, Address, id, "Адрес") + data = body.model_dump(exclude_unset=True) + if data.get("dns_name"): + s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN + _check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id")) + changed = apply_update(a, data) + audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()}) + commit(db) + db.refresh(a) + return _addr_out(a) + + +@router.delete("/addresses/{id}", status_code=204) +def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + a = get_or_404(db, Address, id, "Адрес") + audit(db, user, "address", a, "deleted", s.ip_text(a.address)) + db.delete(a) + commit(db) diff --git a/app/api/v1/refs.py b/app/api/v1/refs.py new file mode 100644 index 0000000..a18bec6 --- /dev/null +++ b/app/api/v1/refs.py @@ -0,0 +1,298 @@ +"""Справочники: организации, VRF, операторы, типы устройств, устройства.""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy import String, cast, func, or_, select +from sqlalchemy.orm import Session + +from app import schemas as s +from app.db import get_db +from app.models import ( + Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf, +) +from app.security import admin_user, current_user +from app.services import apply_update, audit, commit, count, flush, get_or_404 +from fastapi import HTTPException + +router = APIRouter(dependencies=[Depends(current_user)]) + + +def _like(q: str) -> str: + return f"%{q.strip()}%" + + +# ---------------------------------------------------------------- organizations +def _org_out(db: Session, o: Organization) -> s.OrgOut: + out = s.OrgOut.model_validate(o) + out.prefixes_count = count(db, select(Prefix.id).where(Prefix.organization_id == o.id)) + out.addresses_count = count( + db, select(Address.id).join(Prefix).where(Prefix.organization_id == o.id, Address.status == AddressStatus.assigned) + ) + return out + + +@router.get("/organizations", response_model=s.Page[s.OrgOut], tags=["organizations"]) +def list_orgs(q: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db)): + stmt = select(Organization) + if q: + stmt = stmt.where(or_(*(c.ilike(_like(q)) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address)))) + total = count(db, stmt) + rows = db.scalars(stmt.order_by(Organization.id).limit(limit).offset(offset)).all() + return s.Page(items=[_org_out(db, o) for o in rows], total=total) + + +@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"]) +def get_org(id: int, db: Session = Depends(get_db)): + return _org_out(db, get_or_404(db, Organization, id, "Организация")) + + +@router.post("/organizations", response_model=s.OrgOut, status_code=201, tags=["organizations"]) +def create_org(body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + o = Organization(**body.model_dump()) + db.add(o) + flush(db, "Организация с таким названием или ИНН уже существует") + db.add(Vrf(organization_id=o.id, name="default")) + audit(db, user, "organization", o, "created", o.name) + commit(db, "Организация с таким названием или ИНН уже существует") + return _org_out(db, o) + + +@router.patch("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"]) +def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + o = get_or_404(db, Organization, id, "Организация") + changed = apply_update(o, body.model_dump()) + audit(db, user, "organization", o, "updated", o.name, changed) + commit(db, "Организация с таким названием или ИНН уже существует") + return _org_out(db, o) + + +@router.delete("/organizations/{id}", status_code=204, tags=["organizations"]) +def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + o = get_or_404(db, Organization, id, "Организация") + busy = count(db, select(Prefix.id).where(Prefix.organization_id == id)) or count( + db, select(Device.id).where(Device.organization_id == id)) or count(db, select(Isp.id).where(Isp.organization_id == id)) + if busy: + raise HTTPException(409, "Нельзя удалить: у организации есть префиксы, устройства или операторы") + for v in db.scalars(select(Vrf).where(Vrf.organization_id == id)): + db.delete(v) + audit(db, user, "organization", o, "deleted", o.name) + db.delete(o) + commit(db) + + +# ------------------------------------------------------------------------- VRF +def _vrf_out(db: Session, v: Vrf) -> s.VrfOut: + out = s.VrfOut.model_validate(v) + out.prefixes_count = count(db, select(Prefix.id).where(Prefix.vrf_id == v.id)) + return out + + +@router.get("/vrfs", response_model=s.Page[s.VrfOut], tags=["vrf"]) +def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db)): + stmt = select(Vrf) + if organization_id: + stmt = stmt.where(Vrf.organization_id == organization_id) + rows = db.scalars(stmt.order_by(Vrf.id)).all() + return s.Page(items=[_vrf_out(db, v) for v in rows], total=len(rows)) + + +@router.post("/vrfs", response_model=s.VrfOut, status_code=201, tags=["vrf"]) +def create_vrf(body: s.VrfIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + get_or_404(db, Organization, body.organization_id, "Организация") + v = Vrf(**body.model_dump()) + db.add(v) + flush(db, "VRF с таким названием уже есть в организации") + audit(db, user, "vrf", v, "created", v.name) + commit(db, "VRF с таким названием уже есть в организации") + return _vrf_out(db, v) + + +@router.patch("/vrfs/{id}", response_model=s.VrfOut, tags=["vrf"]) +def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): + v = get_or_404(db, Vrf, id, "VRF") + changed = apply_update(v, body.model_dump(exclude_unset=True, exclude_none=True)) + audit(db, user, "vrf", v, "updated", v.name, changed) + commit(db, "VRF с таким названием уже есть в организации") + return _vrf_out(db, v) + + +@router.delete("/vrfs/{id}", status_code=204, tags=["vrf"]) +def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + v = get_or_404(db, Vrf, id, "VRF") + if count(db, select(Prefix.id).where(Prefix.vrf_id == id)): + raise HTTPException(409, "Нельзя удалить: VRF используется префиксами") + audit(db, user, "vrf", v, "deleted", v.name) + db.delete(v) + commit(db) + + +# ---------------------------------------------------------------- device types +def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut: + out = s.DeviceTypeOut.model_validate(t) + out.devices_count = count(db, select(Device.id).where(Device.device_type_id == t.id)) + return out + + +@router.get("/device-types", response_model=s.Page[s.DeviceTypeOut], tags=["devices"]) +def list_types(db: Session = Depends(get_db)): + rows = db.scalars(select(DeviceType).order_by(DeviceType.id)).all() + return s.Page(items=[_type_out(db, t) for t in rows], total=len(rows)) + + +@router.post("/device-types", response_model=s.DeviceTypeOut, status_code=201, tags=["devices"]) +def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + t = DeviceType(name=body.name) + db.add(t) + flush(db, "Тип с таким названием уже существует") + audit(db, user, "device_type", t, "created", t.name) + commit(db, "Тип с таким названием уже существует") + return _type_out(db, t) + + +@router.patch("/device-types/{id}", response_model=s.DeviceTypeOut, tags=["devices"]) +def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + t = get_or_404(db, DeviceType, id, "Тип") + changed = apply_update(t, {"name": body.name}) + audit(db, user, "device_type", t, "updated", t.name, changed) + commit(db, "Тип с таким названием уже существует") + return _type_out(db, t) + + +@router.delete("/device-types/{id}", status_code=204, tags=["devices"]) +def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + t = get_or_404(db, DeviceType, id, "Тип") + if t.is_default: + raise HTTPException(409, "Нельзя удалить тип по умолчанию") + if count(db, select(Device.id).where(Device.device_type_id == id)): + raise HTTPException(409, "Нельзя удалить: тип используется устройствами") + audit(db, user, "device_type", t, "deleted", t.name) + db.delete(t) + commit(db) + + +# --------------------------------------------------------------------- devices +def _device_out(db: Session, d: Device) -> s.DeviceOut: + rows = db.execute( + select(Address.address, Address.prefix_id, Address.status).where(Address.device_id == d.id).order_by(Address.address) + ).all() + t = db.get(DeviceType, d.device_type_id) + return s.DeviceOut( + id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=t.name, + organization_id=d.organization_id, mac=d.mac, note=d.note, + ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None, + all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows), + ) + + +@router.get("/devices", response_model=s.Page[s.DeviceOut], tags=["devices"]) +def list_devices( + organization_id: int | None = None, device_type_id: int | None = None, q: str = "", + limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db), +): + stmt = select(Device) + if organization_id: + stmt = stmt.where(Device.organization_id == organization_id) + if device_type_id: + stmt = stmt.where(Device.device_type_id == device_type_id) + if q: + ip_match = select(Address.device_id).where(func.host(Address.address).ilike(_like(q))) + stmt = stmt.where(or_(Device.name.ilike(_like(q)), Device.note.ilike(_like(q)), Device.id.in_(ip_match))) + total = count(db, stmt) + rows = db.scalars(stmt.order_by(Device.id).limit(limit).offset(offset)).all() + return s.Page(items=[_device_out(db, d) for d in rows], total=total) + + +@router.post("/devices", response_model=s.DeviceOut, status_code=201, tags=["devices"]) +def create_device(body: s.DeviceIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + get_or_404(db, Organization, body.organization_id, "Организация") + get_or_404(db, DeviceType, body.device_type_id, "Тип") + d = Device(**body.model_dump()) + db.add(d) + flush(db, "Устройство с таким именем уже есть в организации") + audit(db, user, "device", d, "created", d.name) + commit(db, "Устройство с таким именем уже есть в организации") + return _device_out(db, d) + + +@router.get("/devices/{id}", response_model=s.DeviceOut, tags=["devices"]) +def get_device(id: int, db: Session = Depends(get_db)): + return _device_out(db, get_or_404(db, Device, id, "Устройство")) + + +@router.patch("/devices/{id}", response_model=s.DeviceOut, tags=["devices"]) +def update_device(id: int, body: s.DeviceUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): + d = get_or_404(db, Device, id, "Устройство") + data = body.model_dump(exclude_unset=True, exclude_none=True) + if "device_type_id" in data: + get_or_404(db, DeviceType, data["device_type_id"], "Тип") + changed = apply_update(d, data) + audit(db, user, "device", d, "updated", d.name, changed) + commit(db, "Устройство с таким именем уже есть в организации") + return _device_out(db, d) + + +@router.delete("/devices/{id}", status_code=204, tags=["devices"]) +def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + d = get_or_404(db, Device, id, "Устройство") + audit(db, user, "device", d, "deleted", d.name) + db.delete(d) + commit(db) + + +# ------------------------------------------------------------------------ ISPs +def _isp_out(db: Session, i: Isp) -> s.IspOut: + org = db.get(Organization, i.organization_id) + return s.IspOut( + id=i.id, name=i.name, organization_id=i.organization_id, organization_name=org.name, + networks=[str(n.cidr) for n in i.networks], hotline=i.hotline, + contract_number=i.contract_number, note=i.note, + ) + + +@router.get("/isps", response_model=s.Page[s.IspOut], tags=["isps"]) +def list_isps( + organization_id: int | None = None, q: str = "", limit: int = Query(100, le=500), offset: int = 0, + db: Session = Depends(get_db), +): + stmt = select(Isp) + if organization_id: + stmt = stmt.where(Isp.organization_id == organization_id) + if q: + nets = select(IspNetwork.isp_id).where(cast(IspNetwork.cidr, String).ilike(_like(q))) + orgs = select(Organization.id).where(Organization.name.ilike(_like(q))) + stmt = stmt.where(or_(Isp.name.ilike(_like(q)), Isp.id.in_(nets), Isp.organization_id.in_(orgs))) + total = count(db, stmt) + rows = db.scalars(stmt.order_by(Isp.id).limit(limit).offset(offset)).all() + return s.Page(items=[_isp_out(db, i) for i in rows], total=total) + + +@router.post("/isps", response_model=s.IspOut, status_code=201, tags=["isps"]) +def create_isp(body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + get_or_404(db, Organization, body.organization_id, "Организация") + data = body.model_dump() + nets = data.pop("networks") + i = Isp(**data, networks=[IspNetwork(cidr=n) for n in nets]) + db.add(i) + db.flush() + audit(db, user, "isp", i, "created", i.name) + commit(db) + return _isp_out(db, i) + + +@router.put("/isps/{id}", response_model=s.IspOut, tags=["isps"]) +def update_isp(id: int, body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + i = get_or_404(db, Isp, id, "Оператор") + get_or_404(db, Organization, body.organization_id, "Организация") + data = body.model_dump() + nets = data.pop("networks") + changed = apply_update(i, data) + i.networks = [IspNetwork(cidr=n) for n in nets] + audit(db, user, "isp", i, "updated", i.name, changed) + commit(db) + return _isp_out(db, i) + + +@router.delete("/isps/{id}", status_code=204, tags=["isps"]) +def delete_isp(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): + i = get_or_404(db, Isp, id, "Оператор") + audit(db, user, "isp", i, "deleted", i.name) + db.delete(i) + commit(db) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..bc16bb7 --- /dev/null +++ b/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+psycopg://ipam:ipam@localhost:55432/ipam" + jwt_secret: str = "dev-only-secret" + jwt_ttl_minutes: int = 480 + admin_username: str = "admin" + admin_password: str = "" + trusted_proxies: str = "" # CIDR через запятую: от них принимается X-Forwarded-For + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..0cf2e72 --- /dev/null +++ b/app/db.py @@ -0,0 +1,12 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.config import settings + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(engine, expire_on_commit=False) + + +def get_db(): + with SessionLocal() as db: + yield db diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e62a7cf --- /dev/null +++ b/app/main.py @@ -0,0 +1,81 @@ +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.api.v1 import auth, journal, overview, prefixes, refs +from app.config import settings +from app.db import SessionLocal +from app.models import DeviceType, Role, User +from app.request_context import RequestContextMiddleware +from app.rotation import rotation_loop +from app.security import hash_password + +DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"] + + +def seed(): + with SessionLocal() as db: + if not db.scalar(select(User.id).limit(1)) and settings.admin_password: + db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin)) + if not db.scalar(select(DeviceType.id).limit(1)): + db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES) + db.commit() + + +@asynccontextmanager +async def lifespan(_: FastAPI): + seed() + task = asyncio.create_task(rotation_loop()) + yield + task.cancel() + + +app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan) +app.add_middleware(RequestContextMiddleware) + +api = APIRouter(prefix="/api/v1") +for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router): + api.include_router(r) +app.include_router(api) + + +@app.exception_handler(HTTPException) +async def http_error(_: Request, exc: HTTPException): + extra = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail} # dict — доп. поля (attempts_left и т.п.) + return JSONResponse({"code": exc.status_code, "fields": {}, **extra}, status_code=exc.status_code, headers=exc.headers) + + +@app.exception_handler(IntegrityError) +async def integrity_error(_: Request, exc: IntegrityError): # страховка: нарушение ограничения БД не должно давать 500 + return JSONResponse({"code": 409, "message": "Конфликт с существующими данными", "fields": {}}, status_code=409) + + +@app.exception_handler(RequestValidationError) +async def validation_error(_: Request, exc: RequestValidationError): + fields = {".".join(str(x) for x in e["loc"][1:]): e["msg"].removeprefix("Value error, ") for e in exc.errors()} + return JSONResponse({"code": 422, "message": "Ошибка валидации", "fields": fields}, status_code=422) + + +@app.get("/healthz", include_in_schema=False) +def healthz(): + return {"status": "ok"} + + +web = Path(__file__).resolve().parent.parent / "web" +if web.is_dir(): + class RevalidatedStatic(StaticFiles): + """Статика с обязательной ревалидацией по ETag: браузер не держит устаревший UI после обновления.""" + + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + response.headers["Cache-Control"] = "no-cache" + return response + + app.mount("/", RevalidatedStatic(directory=web, html=True), name="web") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..51568da --- /dev/null +++ b/app/models.py @@ -0,0 +1,169 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, Boolean, DateTime, Enum, ForeignKey, ForeignKeyConstraint, Index, Integer, String, Text, + UniqueConstraint, func, text, +) +from sqlalchemy.dialects.postgresql import CIDR, INET, JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class PrefixStatus(str, enum.Enum): + active = "active" + reserved = "reserved" + deprecated = "deprecated" + + +class AddressStatus(str, enum.Enum): + assigned = "assigned" + reserved = "reserved" + deprecated = "deprecated" + + +class Role(str, enum.Enum): + admin = "admin" + viewer = "viewer" + + +class Organization(Base): + __tablename__ = "organizations" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255), unique=True) + short_name: Mapped[str] = mapped_column(String(100), default="") + inn: Mapped[str] = mapped_column(String(12), unique=True) + address: Mapped[str] = mapped_column(String(500), default="") + contact_person: Mapped[str] = mapped_column(String(255), default="") + phone: Mapped[str] = mapped_column(String(50), default="") + email: Mapped[str] = mapped_column(String(255), default="") + note: Mapped[str] = mapped_column(Text, default="") + + +class Vrf(Base): + __tablename__ = "vrfs" + # (id, organization_id) — цель составного FK префиксов: VRF префикса всегда из его организации + __table_args__ = (UniqueConstraint("id", "organization_id", name="uq_vrfs_id_organization_id"),) + id: Mapped[int] = mapped_column(primary_key=True) + organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id")) + name: Mapped[str] = mapped_column(String(100)) + route_target: Mapped[str] = mapped_column(String(50), default="") + note: Mapped[str] = mapped_column(Text, default="") + + +Index("uq_vrfs_org_lower_name", Vrf.organization_id, func.lower(Vrf.name), unique=True) # имя VRF уникально в организации без учёта регистра + + +class Prefix(Base): + __tablename__ = "prefixes" + __table_args__ = ( + UniqueConstraint("vrf_id", "prefix"), + ForeignKeyConstraint(["vrf_id", "organization_id"], ["vrfs.id", "vrfs.organization_id"], name="fk_prefixes_vrf_org"), + ) + id: Mapped[int] = mapped_column(primary_key=True) + organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True) + vrf_id: Mapped[int] = mapped_column(ForeignKey("vrfs.id"), index=True) + prefix: Mapped[str] = mapped_column(CIDR) + description: Mapped[str] = mapped_column(String(500), default="") + status: Mapped[PrefixStatus] = mapped_column(Enum(PrefixStatus, name="prefix_status"), default=PrefixStatus.active) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("prefixes.id", ondelete="SET NULL")) + is_pool: Mapped[bool] = mapped_column(Boolean, default=False) + note: Mapped[str] = mapped_column(Text, default="") + + vrf: Mapped[Vrf] = relationship(primaryjoin="Prefix.vrf_id == Vrf.id", foreign_keys=[vrf_id]) + + +class Isp(Base): + __tablename__ = "isps" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True) + contract_number: Mapped[str] = mapped_column(String(100), default="") + hotline: Mapped[str] = mapped_column(String(50), default="") + note: Mapped[str] = mapped_column(Text, default="") + + networks: Mapped[list["IspNetwork"]] = relationship(cascade="all, delete-orphan", order_by="IspNetwork.id") + + +class IspNetwork(Base): + __tablename__ = "isp_networks" + id: Mapped[int] = mapped_column(primary_key=True) + isp_id: Mapped[int] = mapped_column(ForeignKey("isps.id", ondelete="CASCADE"), index=True) + cidr: Mapped[str] = mapped_column(CIDR) + + +class DeviceType(Base): + __tablename__ = "device_types" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + + +class Device(Base): + __tablename__ = "devices" + __table_args__ = (UniqueConstraint("organization_id", "name"),) + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + device_type_id: Mapped[int] = mapped_column(ForeignKey("device_types.id")) + organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True) + mac: Mapped[str] = mapped_column(String(17), default="") + note: Mapped[str] = mapped_column(Text, default="") + + +class Address(Base): + __tablename__ = "addresses" + __table_args__ = (UniqueConstraint("prefix_id", "address"),) + id: Mapped[int] = mapped_column(primary_key=True) + prefix_id: Mapped[int] = mapped_column(ForeignKey("prefixes.id", ondelete="CASCADE"), index=True) + address: Mapped[str] = mapped_column(INET) + status: Mapped[AddressStatus] = mapped_column(Enum(AddressStatus, name="address_status"), default=AddressStatus.assigned) + dns_name: Mapped[str] = mapped_column(String(255), default="") + description: Mapped[str] = mapped_column(String(500), default="") + device_id: Mapped[int | None] = mapped_column(ForeignKey("devices.id", ondelete="SET NULL"), index=True) + note: Mapped[str] = mapped_column(Text, default="") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(100), unique=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.admin) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + +class AuditLog(Base): + __tablename__ = "audit_log" + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + username: Mapped[str] = mapped_column(String(100)) + entity_type: Mapped[str] = mapped_column(String(50), index=True) + entity_id: Mapped[int | None] = mapped_column(Integer) + entity_label: Mapped[str] = mapped_column(String(255)) + action: Mapped[str] = mapped_column(String(20)) + diff: Mapped[dict | None] = mapped_column(JSONB) + uid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), server_default=text("gen_random_uuid()"), unique=True) + message: Mapped[str] = mapped_column(Text, default="", server_default="") + client_ip: Mapped[str | None] = mapped_column(INET, index=True) # IP клиента запроса; NULL для системных событий + meta: Mapped[dict | None] = mapped_column(JSONB) # user_agent, method, path, request_id + + __table_args__ = (Index("ix_audit_log_entity_type_action", "entity_type", "action"),) + + +class AppSetting(Base): + __tablename__ = "app_settings" + key: Mapped[str] = mapped_column(String(50), primary_key=True) + value: Mapped[dict] = mapped_column(JSONB) + + +class ClearAttempt(Base): + """Неудачные попытки подтверждения пароля при очистке журнала (для блокировки).""" + __tablename__ = "clear_attempts" + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/request_context.py b/app/request_context.py new file mode 100644 index 0000000..862b7b9 --- /dev/null +++ b/app/request_context.py @@ -0,0 +1,74 @@ +"""Контекст HTTP-запроса для журнала аудита: IP клиента, User-Agent, метод, путь, request_id.""" +import ipaddress +import uuid +from contextvars import ContextVar + +from app.config import settings + +request_meta: ContextVar[dict | None] = ContextVar("request_meta", default=None) + + +def _parse(value: str): + try: + ip = ipaddress.ip_address(value.strip()) + except ValueError: + return None + return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip # ::ffff:a.b.c.d -> a.b.c.d + + +def parse_networks(raw: str) -> list: + nets = [] + for item in raw.split(","): + if item.strip(): + nets.append(ipaddress.ip_network(item.strip(), strict=False)) + return nets + + +def resolve_client_ip(peer: str | None, x_forwarded_for: str | None, trusted: list) -> str | None: + """IP клиента. X-Forwarded-For учитывается только от доверенного пира (иначе заголовок можно подделать): + идём по цепочке справа налево и берём первый адрес, не принадлежащий доверенным прокси.""" + peer_ip = _parse(peer) if peer else None + if peer_ip is None: + return None + is_trusted = lambda ip: any(ip in net for net in trusted) # noqa: E731 + if not x_forwarded_for or not is_trusted(peer_ip): + return str(peer_ip) + for part in reversed([p for p in x_forwarded_for.split(",") if p.strip()]): + ip = _parse(part) + if ip is None: + return str(peer_ip) # мусор в заголовке — доверяем только сокету + if not is_trusted(ip): + return str(ip) + return str(peer_ip) + + +class RequestContextMiddleware: + """ASGI-middleware: кладёт метаданные запроса в ContextVar и возвращает X-Request-ID.""" + + def __init__(self, app): + self.app = app + self.trusted = parse_networks(settings.trusted_proxies) + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + request_id = uuid.uuid4().hex[:12] + peer = scope["client"][0] if scope.get("client") else None + token = request_meta.set({ + "client_ip": resolve_client_ip(peer, headers.get("x-forwarded-for"), self.trusted), + "meta": { + "user_agent": headers.get("user-agent", "")[:255], "method": scope["method"], + "path": scope["path"], "request_id": request_id, # только путь — без query-строки + }, + }) + + async def send_with_id(message): + if message["type"] == "http.response.start": + message["headers"] = [*message.get("headers", []), (b"x-request-id", request_id.encode())] + await send(message) + + try: + await self.app(scope, receive, send_with_id) + finally: + request_meta.reset(token) diff --git a/app/rotation.py b/app/rotation.py new file mode 100644 index 0000000..1a0eaca --- /dev/null +++ b/app/rotation.py @@ -0,0 +1,74 @@ +"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита.""" +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.db import SessionLocal +from app.models import AppSetting, AuditLog +from app.services import SYSTEM, audit + +log = logging.getLogger("ipam.rotation") +DEFAULTS = {"retention_days": 90, "max_entries": 100_000} +LOCK_KEY = 703001 # advisory lock: одна ротация за раз, даже при нескольких репликах +INTERVAL_SECONDS = 3600 + + +def get_settings(db: Session) -> dict: + row = db.get(AppSetting, "journal") + return {**DEFAULTS, **(row.value if row else {})} + + +def save_settings(db: Session, values: dict) -> None: + row = db.get(AppSetting, "journal") + if row is None: + db.add(AppSetting(key="journal", value=values)) + else: + row.value = values + + +def rotate(db: Session) -> dict | None: + """Возвращает {'by_age', 'by_count'} или None, если ротацию уже выполняет другой процесс.""" + if not db.scalar(select(func.pg_try_advisory_xact_lock(LOCK_KEY))): + db.rollback() + return None + cfg = get_settings(db) + by_age = by_count = 0 + if cfg["retention_days"] > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=cfg["retention_days"]) + by_age = db.execute(delete(AuditLog).where(AuditLog.ts < cutoff)).rowcount + if cfg["max_entries"] > 0: + total = db.scalar(select(func.count()).select_from(AuditLog)) or 0 + if total > cfg["max_entries"]: + # удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит + n = total - cfg["max_entries"] + 1 + by_count = db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(n)))).rowcount + if by_age or by_count: + parts = [] + if by_age: + parts.append(f"старше {cfg['retention_days']} дн.: {by_age}") + if by_count: + parts.append(f"сверх лимита {cfg['max_entries']}: {by_count}") + audit(db, SYSTEM, "journal", None, "rotated", "rotation", {"by_age": by_age, "by_count": by_count}, + message="Ротация журнала: удалено " + "; ".join(parts)) + db.commit() + return {"by_age": by_age, "by_count": by_count} + + +def run_rotation() -> None: + try: + with SessionLocal() as db: + result = rotate(db) + if result and (result["by_age"] or result["by_count"]): + log.info("journal rotated: %s", result) + except Exception: # фоновая задача не должна падать + log.exception("journal rotation failed") + + +async def rotation_loop() -> None: + await asyncio.sleep(30) + while True: + await asyncio.to_thread(run_rotation) + await asyncio.sleep(INTERVAL_SECONDS) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..f1537fd --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,301 @@ +import ipaddress +import re +from datetime import datetime +from typing import Annotated, Generic, TypeVar + +from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator + +from app.models import AddressStatus, PrefixStatus + +T = TypeVar("T") + + +def _cidr(v: str) -> str: + try: + return str(ipaddress.ip_network(v.strip(), strict=True)) + except ValueError: + raise ValueError("Некорректный CIDR (пример: 10.30.0.0/24, биты хоста должны быть нулевыми)") + + +def _ip(v: str) -> str: + try: + return str(ipaddress.ip_address(v.strip())) + except ValueError: + raise ValueError("Некорректный IP-адрес") + + +Cidr = Annotated[str, AfterValidator(_cidr)] +IpAddr = Annotated[str, AfterValidator(_ip)] +_FQDN = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)(\.[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)*$") +_MAC = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$") + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + + +class ORM(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +# --- auth +class LoginIn(BaseModel): + username: str + password: str + + +class TokenOut(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserOut(ORM): + username: str + role: str + + +# --- organizations +class OrgIn(BaseModel): + name: str = Field(min_length=1, max_length=255) + short_name: str = Field("", max_length=100) + inn: str = Field(pattern=r"^(\d{10}|\d{12})$") + address: str = Field("", max_length=500) + contact_person: str = "" + phone: str = "" + email: EmailStr | str = "" + note: str = "" + + @field_validator("email") + @classmethod + def _email(cls, v): + return str(v) + + +class OrgOut(ORM, OrgIn): + id: int + prefixes_count: int = 0 + addresses_count: int = 0 + + +# --- vrf +class VrfIn(BaseModel): + organization_id: int + name: str = Field(min_length=1, max_length=100) + route_target: str = Field("", max_length=50, pattern=r"^(\d+:\d+)?$") + note: str = "" + + +class VrfUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + route_target: str | None = Field(None, max_length=50, pattern=r"^(\d+:\d+)?$") + note: str | None = None + + +class VrfOut(ORM): + id: int + organization_id: int + name: str + route_target: str + note: str + prefixes_count: int = 0 + + +# --- device types / devices +class DeviceTypeIn(BaseModel): + name: str = Field(min_length=1, max_length=100) + + +class DeviceTypeOut(ORM): + id: int + name: str + is_default: bool + devices_count: int = 0 + + +class DeviceIn(BaseModel): + name: str = Field(min_length=1, max_length=255) + device_type_id: int + organization_id: int + mac: str = "" + note: str = "" + + @field_validator("name") + @classmethod + def _name(cls, v): + if not _FQDN.match(v): + raise ValueError("Некорректное имя устройства (hostname)") + return v + + @field_validator("mac") + @classmethod + def _mac(cls, v): + if v and not _MAC.match(v): + raise ValueError("Некорректный MAC-адрес (AA:BB:CC:DD:EE:FF)") + return v.upper().replace("-", ":") + + +class DeviceUpdate(BaseModel): + name: str | None = None + device_type_id: int | None = None + mac: str | None = None + note: str | None = None + + +class DeviceOut(ORM): + id: int + name: str + device_type_id: int + device_type_name: str + organization_id: int + mac: str + note: str + ip_addresses: list[str] = [] + first_prefix_id: int | None = None + all_deprecated: bool = False + + +# --- isp +class IspIn(BaseModel): + name: str = Field(min_length=1, max_length=255) + organization_id: int + networks: list[Cidr] = [] + hotline: str = Field("", max_length=50) + contract_number: str = Field("", max_length=100) + note: str = "" + + +class IspOut(ORM): + id: int + name: str + organization_id: int + organization_name: str + networks: list[str] + hotline: str + contract_number: str + note: str + + +# --- prefixes +class PrefixIn(BaseModel): + organization_id: int + vrf_id: int + prefix: Cidr + description: str = Field("", max_length=500) + status: PrefixStatus = PrefixStatus.active + parent_id: int | None = None + is_pool: bool = False + note: str = "" + + +class PrefixUpdate(BaseModel): + vrf_id: int | None = None + description: str | None = Field(None, max_length=500) + status: PrefixStatus | None = None + is_pool: bool | None = None + note: str | None = None + + +class PrefixOut(ORM): + id: int + organization_id: int + vrf_id: int + vrf_name: str + prefix: str + family: int + description: str + status: PrefixStatus + parent_id: int | None + depth: int + is_pool: bool + note: str + used: int + capacity: int + utilization: int + addresses_count: int + + +# --- addresses +class AddressIn(BaseModel): + address: IpAddr + status: AddressStatus = AddressStatus.assigned + dns_name: str = "" + description: str = Field("", max_length=500) + device_id: int | None = None + note: str = "" + + @field_validator("dns_name") + @classmethod + def _dns(cls, v): + if v and not _FQDN.match(v): + raise ValueError("Некорректное DNS-имя (FQDN)") + return v + + +class AddressUpdate(BaseModel): + status: AddressStatus | None = None + dns_name: str | None = None + description: str | None = Field(None, max_length=500) + device_id: int | None = None + note: str | None = None + + +class AddressOut(BaseModel): + id: int | None + prefix_id: int + address: str + status: str # assigned | reserved | deprecated | free + dns_name: str = "" + description: str = "" + device_id: int | None = None + device_name: str | None = None + note: str = "" + updated_at: datetime | None = None + + +class AddressSummary(BaseModel): + assigned: int + reserved: int + deprecated: int + free: int + capacity: int + + +class AddressPage(Page[AddressOut]): + summary: AddressSummary + + +class AuditOut(ORM): + id: int + uid: str + short_id: str + ts: datetime + username: str + actor: str + entity_type: str + entity_id: int | None + entity_label: str + action: str + event_type: str + message: str + diff: dict | None + client_ip: str | None = None + meta: dict | None = None + + @classmethod + def from_row(cls, r) -> "AuditOut": + return cls( + id=r.id, uid=str(r.uid), short_id=str(r.uid)[:8], ts=r.ts, username=r.username, + actor=r.username if r.username in ("system", "anonymous") else f"ui:{r.username}", + entity_type=r.entity_type, entity_id=r.entity_id, entity_label=r.entity_label, action=r.action, + event_type=f"{r.entity_type}.{r.action}", message=r.message, diff=r.diff, + client_ip=ip_text(r.client_ip) if r.client_ip is not None else None, meta=r.meta, + ) + + +def ip_text(v) -> str: + """psycopg отдаёт inet/cidr объектами ipaddress; для хоста убираем /32 и /128.""" + text = str(v) + if text.endswith("/32") or text.endswith("/128"): + return text.rsplit("/", 1)[0] + return text diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..f2648a3 --- /dev/null +++ b/app/security.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.db import get_db +from app.models import Role, User + +_ph = PasswordHasher() +_bearer = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + try: + return _ph.verify(hashed, password) + except VerifyMismatchError: + return False + + +def create_token(user: User) -> str: + exp = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_ttl_minutes) + return jwt.encode({"sub": user.username, "exp": exp}, settings.jwt_secret, algorithm="HS256") + + +def current_user( + cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db) +) -> User: + if cred is None: + raise HTTPException(401, "Требуется авторизация") + try: + username = jwt.decode(cred.credentials, settings.jwt_secret, algorithms=["HS256"])["sub"] + except jwt.PyJWTError: + raise HTTPException(401, "Недействительный токен") + user = db.scalar(select(User).where(User.username == username, User.is_active)) + if user is None: + raise HTTPException(401, "Пользователь не найден") + return user + + +def admin_user(user: User = Depends(current_user)) -> User: + if user.role != Role.admin: + raise HTTPException(403, "Недостаточно прав") + return user diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..5042ea8 --- /dev/null +++ b/app/services.py @@ -0,0 +1,107 @@ +import ipaddress +from types import SimpleNamespace + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.request_context import request_meta +from app.models import Address, AuditLog, Base + +MAX_CAPACITY = 2**53 - 1 + + +SYSTEM = SimpleNamespace(username="system") +ANONYMOUS = SimpleNamespace(username="anonymous") +_NOUNS = { + "organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"), + "device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"), +} +_VERBS = { + "created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"), + "deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено"), +} + + +def make_message(entity_type: str, action: str, label: str, diff: dict | None = None) -> str: + noun, gender = _NOUNS.get(entity_type, (entity_type, "m")) + verb = _VERBS.get(action, (action,) * 3)["mfn".index(gender)] + text = f"{noun} {label} {verb}" + if action == "updated" and diff: + text += ": " + ", ".join(diff) + return text + + +def actor_of(username: str) -> str: + """Актор для журнала: служебные — как есть, пользователи UI — с префиксом ui:.""" + return username if username in ("system", "anonymous") else f"ui:{username}" + + +def audit(db: Session, user, entity_type: str, entity, action: str, label: str, diff: dict | None = None, message: str | None = None): + ctx = None if user is SYSTEM else request_meta.get() # системные события (ротация) — без IP, даже если запущены из запроса + db.add(AuditLog( + username=user.username, entity_type=entity_type, entity_id=getattr(entity, "id", None), + entity_label=label, action=action, diff=diff, + message=message or make_message(entity_type, action, label, diff), + client_ip=ctx["client_ip"] if ctx else None, meta=ctx["meta"] if ctx else None, + )) + + +def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"): + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(409, conflict_msg) + + +def flush(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"): + """flush с тем же переводом нарушений уникальности в 409, что и commit.""" + try: + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException(409, conflict_msg) + + +def get_or_404(db: Session, model: type[Base], id_: int, what: str = "Объект"): + obj = db.get(model, id_) + if obj is None: + raise HTTPException(404, f"{what} не найден") + return obj + + +def capacity(prefix: str) -> int: + net = ipaddress.ip_network(prefix) + n = net.num_addresses + if net.version == 4 and net.prefixlen <= 30: + n -= 2 # сеть и broadcast + return min(n, MAX_CAPACITY) + + +def utilization(used: int, cap: int) -> int: + return round(used * 100 / cap) if cap else 0 + + +def apply_update(obj, data: dict) -> dict: + changed = {} + for k, v in data.items(): + if getattr(obj, k) != v: + changed[k] = v + setattr(obj, k, v) + return changed + + +def count(db: Session, stmt) -> int: + return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + + +def next_free(db: Session, prefix_id: int, prefix: str) -> str | None: + net = ipaddress.ip_network(prefix) + used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))} + hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net) + for ip in hosts: + if ip not in used: + return str(ip) + return None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..316104c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: [pgdata:/var/lib/postgresql/data] + ports: ["127.0.0.1:${DB_HOST_PORT}:5432"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 3s + retries: 20 + app: + build: . + depends_on: + db: {condition: service_healthy} + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} + ports: ["0.0.0.0:${APP_PORT}:8000"] +volumes: + pgdata: diff --git a/docs/changes/001-ipam-backend/PLAN.md b/docs/changes/001-ipam-backend/PLAN.md new file mode 100644 index 0000000..4b61534 --- /dev/null +++ b/docs/changes/001-ipam-backend/PLAN.md @@ -0,0 +1,96 @@ +# IPAM Manager — backend (Python + PostgreSQL) и UI-админка + +## Context +Есть макеты «IPAM Manager» (страница 2 канваса): Обзор, Префиксы (дерево, VRF), Адреса в подсети, +Организации, Операторы связи, Устройства (+ типы), Журнал, диалоги создания/редактирования. +Проект пуст. Нужно спроектировать и реализовать API-first приложение: backend на Python с PostgreSQL, +UI-админка — отдельный лёгкий фронт (без сборки), который только визуализирует ответы API. + +Решения (согласованы): FastAPI + SQLAlchemy 2 + Alembic + psycopg3; UI — статический SPA (Alpine.js + fetch), +раздаётся тем же приложением; auth — локальные пользователи + JWT; окружение — Docker Compose (postgres + app), +venv в корне для тестов/линтеров. + +## Доменная модель (PostgreSQL) +Нативные типы `CIDR` / `INET` — сравнения, вложенность (`<<=`, `>>=`) и семейство (`family()`) считает БД. + +| Таблица | Ключевые поля | +|---|---| +| `organizations` | name, short_name, inn (uniq), address, contact_person, phone, email, note | +| `vrfs` | organization_id, name, route_target, note; uniq(org, name) | +| `prefixes` | organization_id, vrf_id, prefix CIDR, description, status (active/reserved/deprecated), parent_id (авто по вложенности, можно задать), is_pool, note; uniq(vrf, prefix) | +| `addresses` | prefix_id, address INET, status (assigned/reserved/deprecated), dns_name, description, device_id, note, updated_at; uniq(prefix, address); CHECK «адрес ∈ префикс» на уровне сервиса | +| `isps` | name, organization_id, contract_number, hotline, note | +| `isp_networks` | isp_id, cidr (несколько IP-блоков на оператора) | +| `device_types` | name (uniq), is_default | +| `devices` | name (hostname), device_type_id, mac, organization_id, note | +| `users` | username (uniq), password_hash (argon2), role (admin/viewer), is_active | +| `audit_log` | ts, user_id, entity_type, entity_id, entity_label, action (created/updated/deleted/assigned), diff JSONB | + +Вычисляемое (не хранится): использование префикса (назначено/ёмкость), «свободные» адреса (в макете статус +«Свободен» — пропуски в диапазоне), число префиксов/адресов у организации, число устройств у типа, число IP у устройства. +Правила: VRF/тип нельзя удалить, пока используются (в макете кнопка disabled) → 409. + +## API (`/api/v1`, OpenAPI на `/docs`) +- `auth`: `POST /auth/login`, `GET /auth/me` (logout — на клиенте, токен короткоживущий). +- `overview`: `GET /overview` — карточки (префиксов, VRF, адресов, использование, резерв), топ загрузки, последние изменения. +- `organizations`, `isps`, `devices`, `device-types`, `vrfs`: CRUD; списки с `q`, `limit`, `offset`, фильтры (org, тип, vrf). +- `prefixes`: CRUD; `GET /prefixes?org=&vrf=&status=&family=&q=` (дерево через parent_id + utilization); + счётчики вкладок VRF; `GET /prefixes/{id}`. +- `prefixes/{id}/addresses`: список (`status`, `q`, `limit`/`offset` — «Показать ещё 100»), `POST` назначить, + `POST .../next` — автоназначение из пула (`is_pool`), сводка «назначено/резерв/свободно». +- `addresses/{id}`: PATCH/DELETE. +- `audit`: `GET /audit` (фильтры, пагинация) — источник «Последних изменений»; полный экран «Журнал» — вне этого этапа. +- Единый формат ошибок `{code, message, fields}`; валидация CIDR/IP/MAC/FQDN в pydantic; 409 на дубли и пересечения. +- Запись в `audit_log` — в сервисном слое в одной транзакции с изменением. + +## Структура репозитория +``` +app/ main.py config.py db.py security.py + models/ schemas/ services/ api/v1/ +alembic/ (миграция 0001 — вся схема + seed: admin, типы устройств) +web/ index.html, app.js, api.js, styles.css (IBM Plex, токены цвета из макетов) +tests/ (минимум) +docs/changes/001-ipam-backend/ PLAN.md, SUMMARY.md +docker-compose.yml Dockerfile .env.example requirements.txt README.md venv/ (в .gitignore) +``` + +## UI (web/) +Экраны 1:1 с макетами: логин, Обзор, Префиксы (вкладки VRF, фильтры, «Управление VRF»), Адреса подсети, +Организации, Операторы, Устройства («Управление типами»), модальные формы. Только `fetch` к `/api/v1`, JWT в +`sessionStorage`; 401 → экран логина. Пункт «Журнал» — заглушка со ссылкой на `/audit` (по вопросу №1 из макета). + +## Порядок работ (по артефактам из CLAUDE.md) +0. Создать `docs/changes/001-ipam-backend/PLAN.md` (этот план) — до кода. +1. Каркас: docker-compose (postgres:16 + app), Dockerfile, config, venv, Alembic. +2. Модели + миграция 0001 + seed. +3. Auth (login/JWT/роли; viewer — только чтение). +4. Справочники: организации, VRF, операторы, типы, устройства. +5. Префиксы и адреса: вложенность, использование, next-free, проверка «адрес ∈ префикс», пересечения в VRF. +6. Обзор + audit. +7. UI SPA. +8. Тесты, обновить `README.md`, написать `SUMMARY.md`. + +## Тесты (минимум, pytest + реальный Postgres из compose) +1. Логин → защищённый эндпоинт (401 без токена, 200 с токеном). +2. Префикс: дубль в VRF → 409; адрес вне префикса → 422. +3. Использование префикса и next-free считаются верно. +4. Удаление VRF/типа «в использовании» → 409. + +## Проверка end-to-end +`docker compose up -d --build` → `alembic upgrade head` отрабатывает автоматически → `pytest` зелёный → +открыть `http://localhost:8000/` (UI), войти `admin`, пройти сценарий: организация → VRF → префикс → +назначить адрес → увидеть его в Обзоре и в audit. `http://localhost:8000/docs` — Swagger. + +## Допущения (скажите, если не так) +- VRF принадлежит организации (в макете «общий список для организации»). +- Свободные адреса вычисляются, а не хранятся. +- Экран «Журнал» с ротацией/очисткой (страница ROS Manager) в этот этап не входит — только запись и чтение audit. +- Начальный пароль admin задаётся через `.env`, не хардкодится. + +## Уточнения по итогам утверждения +- План утверждён пользователем; сохранён в этом файле до начала кода. +- Тесты выполняются против приложения, поднятого в контейнерах (docker compose). Учётные данные для тестов + генерируются автоматически (случайные пароль admin и секрет JWT в `.env`, файл в .gitignore). Контейнеры локальные, без внешнего доступа. +- Сборка и запуск вспомогательных инструментов (pytest, линтеры, alembic) — из `venv/` в корне проекта. +- Обязательная сверка UI собранного приложения с макетами (все экраны и диалоги страницы «IPAM Manager»); + расхождения устраняются до завершения работы, результат сверки фиксируется в SUMMARY.md. diff --git a/docs/changes/001-ipam-backend/SUMMARY.md b/docs/changes/001-ipam-backend/SUMMARY.md new file mode 100644 index 0000000..65c44a0 --- /dev/null +++ b/docs/changes/001-ipam-backend/SUMMARY.md @@ -0,0 +1,28 @@ +# Суммаризация: backend + UI IPAM Manager (изменение 001) + +## Сделано +- **Backend** (FastAPI): 9 групп эндпоинтов по плану — auth/JWT, обзор, организации, VRF, операторы, типы устройств, + устройства, префиксы (дерево, использование, автоназначение), адреса, журнал изменений. +- **БД**: PostgreSQL 16 в Docker Compose, схема — миграция Alembic `0001` (сгенерирована из моделей), нативные `CIDR`/`INET`. +- **UI** (`web/`): все экраны и диалоги страницы «IPAM Manager» — Обзор, Префиксы (дерево, вкладки VRF, фильтры), + Адреса подсети, Организации, Операторы, Устройства, диалоги префикса/адреса/VRF/организации/оператора/устройства/типов; плюс логин и простой «Журнал». +- **Окружение**: `docker-compose.yml`, `Dockerfile`, `scripts/gen_env.py` (случайные учётные данные), `scripts/seed_demo.py`, `venv/` в корне. +- **Документация**: `README.md`, `PLAN.md`, этот файл. + +## Проверка +- 4 автотеста (auth; валидация префикса/адреса; дерево, использование, автоназначение; запрет удаления используемых объектов) — против контейнеров, **4 passed**. +- Сквозной сценарий UI в headless-браузере (вход/ошибка входа, организация, VRF, префикс, адреса, устройства, типы, операторы, журнал, выход) — пройден, ошибок JS в консоли нет + (кроме ожидаемых 401/422 в негативных шагах). +- **Сверка с макетами**: все 13 экранов/диалогов отрендерены рядом с макетом (1440 px) и просмотрены. Структура, сетка, размеры, + цвета, шрифты, иконки, бейджи, состояния (наведение, зачёркнутое устройство, «Свободен») совпадают. Оставшиеся отличия — только данные + (в макетах вымышленные значения и предзаполненные формы) и состояния, которых нет в статичных макетах (фокус поля, раскрытие меню). + +## Осознанные отступления и допущения +1. В макете «Префиксы» у `10.10.1.0/24` в колонке «Статус» стоит бейдж «95%» — похоже на дефект макета (это загрузка); реализовано «Активен», загрузка — в колонке «Использование». +2. Ёмкость родительского префикса = сумма вложенных листьев (иначе `10.0.0.0/8` всегда «0%»); Обзор считает только IPv4. +3. По умолчанию в дереве раскрыта первая ветка (как в макете), остальные свёрнуты. +4. Экран «Журнал» для IPAM в макетах отсутствует — сделана простая таблица `audit_log`; ротация/очистка (страница ROS Manager) не входит. +5. Порт приложения 8088 (8000 на машине занят). + +## Не вошло / дальше +Управление пользователями (создание viewer-ов), пагинация «Показать ещё» для организаций/устройств, импорт/экспорт, экран журнала с фильтрами. diff --git a/docs/changes/002-prefix-vrf-change/PLAN.md b/docs/changes/002-prefix-vrf-change/PLAN.md new file mode 100644 index 0000000..268a4f0 --- /dev/null +++ b/docs/changes/002-prefix-vrf-change/PLAN.md @@ -0,0 +1,51 @@ +# План: смена VRF у префикса и целостность «VRF ⊂ организация» (изменение 002) + +## Контекст +VRF — часть адресного плана организации. Сейчас: +- уникальность VRF уже в пределах организации: `UNIQUE(organization_id, name)` — одно имя допустимо в разных организациях; +- несколько префиксов организации могут быть в одном VRF (`UNIQUE(vrf_id, prefix)` — дубль CIDR запрещён только внутри VRF); +- **смена VRF у существующего префикса невозможна**: нет ни поля в UI (`web/app.js`, `prefixDialog`), ни `vrf_id` в `PrefixUpdate` (`app/schemas.py`); +- принадлежность VRF организации префикса проверяется только в коде (`create_prefix`), в БД гарантии нет. + +Цель: разрешить смену VRF **только среди VRF той же организации**, сохранив целостность дерева, и закрепить правило на уровне БД. + +## Правила +1. Организация префикса неизменна; целевой VRF обязан принадлежать той же организации (иначе 422). +2. Смена VRF переносит **префикс вместе со всем поддеревом** (вложенные по `parent_id`), чтобы дерево не «разрывалось» между VRF. +3. Дубль `(vrf, prefix)` у любого переносимого префикса в целевом VRF → 409 с перечнем конфликтующих CIDR; перенос отменяется целиком. +4. После переноса родитель корня поддерева пересчитывается в целевом VRF (самый узкий объемлющий); префиксы целевого VRF, лежащие внутри перенесённого, переподчиняются ему — та же логика, что при создании (выносим в общую функцию `attach_to_tree`). +5. Адреса остаются у своих префиксов (`prefix_id` не меняется); использование/ёмкость пересчитываются автоматически. +6. CIDR и родитель в окне редактирования по-прежнему не меняются (родитель определяется автоматически). + +## Изменения +**Backend** +- `app/schemas.py`: `PrefixUpdate.vrf_id: int | None`. +- `app/api/v1/prefixes.py`: в `update_prefix` — валидация правил 1–3, перенос поддерева, `attach_to_tree` (рефакторинг из `create_prefix`), запись в audit `{vrf: "old → new", moved: N}`. +- Alembic `0002`: + - `UNIQUE(id, organization_id)` на `vrfs` + составной FK `prefixes(vrf_id, organization_id) → vrfs(id, organization_id)` — БД не даст связать префикс с VRF чужой организации; + - уникальность имени VRF без учёта регистра в пределах организации: индекс `(organization_id, lower(name))` вместо `UNIQUE(organization_id, name)`; перед применением — проверка на существующие дубли. + +**UI (`web/app.js`)** +- В окне редактирования префикса показать поле «VRF» (только VRF текущей организации, уже загружены в `S.vrfs`). +- При выборе другого VRF — подсказка «Вместе с префиксом будет перенесено N вложенных» (N считается из `S.prefixes`); ошибки 409/422 — в стандартном баннере окна. +- Вёрстка — из существующего окна создания (макета редактирования нет, отступлений от дизайна не появляется). + +**Тесты (2 новых, всего 6)** +1. Одноимённый VRF в двух организациях — ок; повтор в одной (в т.ч. другим регистром) — 409. +2. Смена VRF: поддерево переехало, родитель пересчитан, дубль в целевом VRF — 409 без частичных изменений, VRF другой организации — 422. + +## Порядок работ +1. Рефакторинг `attach_to_tree` (поведение создания не меняется, тесты зелёные). +2. Миграция `0002` (сначала проверка дублей, затем применение через `docker compose up`). +3. `PrefixUpdate` + логика переноса. +4. UI: поле VRF и подсказка. +5. Тесты в контейнерах, прогон e2e-сценария UI, сверка окна с макетом «Новый префикс». +6. Обновить `README.md` (правила VRF), написать `SUMMARY.md` в этой папке. + +## Риски +- Составной FK требует, чтобы `prefixes.organization_id` всегда совпадал с организацией VRF — уже так; миграция проверит данные и откажется применяться при расхождении. +- Перенос крупного поддерева — одна транзакция; при 409 откат целиком. + +## Допущения (скажите, если не так) +- Переносится всё поддерево, а не только выбранный префикс (альтернатива — запретить смену VRF у префиксов с вложенными). +- Регистронезависимая уникальность имён VRF в организации допустима (`Lab` и `lab` считаются одним VRF). diff --git a/docs/changes/002-prefix-vrf-change/SUMMARY.md b/docs/changes/002-prefix-vrf-change/SUMMARY.md new file mode 100644 index 0000000..205f9ee --- /dev/null +++ b/docs/changes/002-prefix-vrf-change/SUMMARY.md @@ -0,0 +1,31 @@ +# Суммаризация: смена VRF у префикса и целостность «VRF ⊂ организация» (изменение 002) + +## Сделано +- **API:** `PATCH /prefixes/{id}` принимает `vrf_id`. Перенос — только в VRF той же организации (иначе 422), вместе со всем поддеревом; + дубль CIDR в целевом VRF → 409 со списком конфликтов, откат целиком. Родитель пересчитывается в целевом VRF; логика подбора родителя + вынесена в общую `attach_to_tree` (её же использует создание префикса). В журнал пишется `VRF: старый → новый` и число перенесённых. +- **БД (миграция 0002):** `UNIQUE(id, organization_id)` на `vrfs` + составной FK `prefixes(vrf_id, organization_id) → vrfs`; + имя VRF уникально в организации без учёта регистра (`uq_vrfs_org_lower_name`). Миграция сама проверяет данные на дубли/расхождения. + Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`. +- **UI:** в окне редактирования префикса появилось поле «VRF» (только VRF текущей организации), подсказка «Вместе с префиксом будет + перенесено вложенных: N» при выборе другого VRF; ошибки 409/422 — в баннере окна. +- **Тесты:** +2 (всего 6, все проходят против контейнеров): уникальность имён VRF по организациям; перенос поддерева (409 / 422 / успех). + +## Найдено и исправлено попутно +- Дубликат при создании (организация, VRF, тип, устройство, адрес, префикс) приводил к **500** вместо 409: ошибка уникальности возникала на `flush`, + минуя обработку. Добавлен `services.flush` с тем же переводом в 409 и глобальный обработчик `IntegrityError` как страховка. +- Меню действий строки оставалось на экране под открытым диалогом после выбора пункта. + +## Ограничения +- В UI кнопка «⋯» есть только у листовых префиксов (как в макете), поэтому перенос целого поддерева через интерфейс недоступен — только через API; + подсказка про вложенные в UI готова и появится, если добавить действие у родительских префиксов. + +## Дополнение: локальные шрифты +IBM Plex Sans (400/500/600) и Mono (400/500) в формате woff2 (latin + cyrillic, ~150 КБ всего) лежат в `web/fonts/`, подключены через `fonts/fonts.css` +(`@font-face` с `unicode-range`); ссылка на fonts.googleapis.com удалена. Проверено: при загрузке UI нет ни одного запроса за пределы приложения. + +## Дополнение: выпадающие списки в диалогах +- Баг: в окне «Редактировать префикс» на экране адресов список VRF был пуст (VRF загружались только на экране «Префиксы») — теперь запрашиваются и на экране адресов. +- Нативный ``; +const currentOrg = () => orgs.find((o) => o.id === store.orgId); + +function popMenu(id, items, extra = "") { + if (menu?.id !== id) return ""; + return ``; +} +function filterBtn(id, label, items, value, width = 180) { + const cur = items.find((i) => String(i.value) === String(value)); + return `${popMenu(id, items.map((i) => ({ ...i, cls: String(i.value) === String(value) ? "sel" : "" })), "")}`; +} +function orgSwitcher() { + const o = currentOrg(); + return `${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}
`; +} +const crumbsOrg = () => `
Организации${I.chevR(14)}${esc(currentOrg()?.name ?? "")}
`; +const header = (title, sub, actions = "") => `

${esc(title)}

${sub}
${actions}
`; + +const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"]]; +function shell(active, content) { + return `
ipam_manager
+ +
${esc(user?.username ?? "")}${btn("Выйти", "logout", { cls: "ghost" })}
+
${content}
`; +} + +/* ------------------------------------------------------------------ dialogs */ +function openDialog({ title, width = 560, note = "", body, foot }) { + $("#modal-root").innerHTML = `
`; + $("#dlg-form .input, #dlg-form .select")?.focus(); +} +const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; }; +const opt = (label) => ` ${label}`; +const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) => + ``; +const fArea = (name, label, { value = "", ph = "Любая дополнительная информация", rows = 2, h = 56, mono = false, optional = true, hint = "" } = {}) => + ``; +// Выпадающий список в диалогах — тот же стиль, что у меню строк (не нативный + +`; +}; +const dlgFoot = (submit) => `
${btn("Отмена", "close-dialog", { cls: "ghost" })}${btn(submit, "submit-form", { cls: "primary" })}
`; +const formBody = (inner) => `
${inner}
`; + +function readForm() { + const out = {}; + for (const el of $("#dlg-form").elements) { + if (!el.name) continue; + out[el.name] = el.type === "checkbox" ? el.checked : el.value.trim(); + } + return out; +} +function showFormError(e) { + const box = $("#dlg-err"); + if (!box) return toast(e.message, true); + const msgs = Object.entries(e.fields || {}).map(([f, m]) => (f ? `${f}: ${m}` : m)); + box.textContent = msgs.length ? msgs.join(" · ") : e.message; + box.hidden = false; + for (const f of Object.keys(e.fields || {})) $(`#dlg-form [name="${f.split(".")[0]}"]`)?.classList.add("bad"); +} +async function submitDialog() { + const h = S.dialog; + try { + await h(readForm()); + closeDialog(); + } catch (e) { + if (e instanceof ApiError) showFormError(e); else throw e; + } +} + +/* ------------------------------------------------------------------- screens */ +const screens = {}; + +// ---- overview +screens.overview = async () => { + const o = await api("/overview"); + const [, tc] = utilColor(o.utilization); + const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"] }; + const top = o.top_prefixes.map((p, i, a) => { + const [bar, txt] = utilColor(p.utilization); + return `
${esc(p.prefix)}${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)
`; + }).join(""); + const recent = o.recent_changes.map((r, i, a) => { + const [c, t] = actionBadge[r.action] || ["", r.action]; + return `
${fmtDate(r.ts)}${esc(r.entity_label)}${badge(c, t)}${esc(r.username)}
`; + }).join(""); + return shell("overview", `${header("Обзор", "Сводка по адресному пространству")} +
+
Префиксов
${fmtNum(o.prefixes)}
${o.vrfs} VRF
+
Адресов назначено
${fmtNum(o.assigned)}
из ${fmtNum(o.capacity)} возможных
+
Использование
${o.utilization}%
+
Зарезервировано
${fmtNum(o.reserved)}
адресов
+
Высокая загрузка
+
ПрефиксИспользовано
${top || '
Нет данных
'}
+
Последние изменения
+
Дата (UTC)ОбъектДействиеПользователь
${recent || '
Изменений пока нет
'}
`); +}; + +// ---- organizations +screens.orgs = async () => { + const q = S.q || ""; + const { items, total } = await api("/organizations", { params: { q, limit: 500 } }); + const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total; + const rows = items.map((o, i) => `
+${esc(o.name)}${esc(o.short_name)}${esc(o.inn)} +${esc(o.address)}${o.prefixes_count}${fmtNum(o.addresses_count)} +${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}
`).join(""); + S.rows = items; + return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))} +
${searchBox(q, "Поиск: название, ИНН, адрес", 300)}
+
НазваниеКраткое имяИННАдресПрефиксовАдресов
+${rows || '
Ничего не найдено
'}
Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}
`); +}; +function orgDialog(o) { + const edit = !!o; + S.dialog = async (v) => { + await api(edit ? `/organizations/${o.id}` : "/organizations", { method: edit ? "PATCH" : "POST", body: v }); + toast(edit ? "Организация сохранена" : "Организация добавлена"); + await loadOrgs(); + await draw(); + }; + openDialog({ + title: edit ? "Редактирование организации" : "Новая организация", + body: formBody(`${fInput("name", "Название", { value: o?.name })} +
${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}
+${fInput("address", "Юридический адрес", { value: o?.address })} +
${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}
+${fInput("email", "Email", { value: o?.email, optional: true, ph: "email@example.com" })}${fArea("note", "Примечание", { value: o?.note })}`), + foot: dlgFoot(edit ? "Сохранить" : "Добавить организацию"), + }); +} + +// ---- ISPs +screens.isps = async () => { + const q = S.q || ""; + const { items } = await api("/isps", { params: { q, limit: 500 } }); + const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length; + S.rows = items; + const cols = "minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px"; + const rows = items.map((i, n) => `
${esc(i.name)} +${esc(i.organization_name)} +${esc(i.networks[0] ?? "—")}${i.networks.length > 1 ? `+${i.networks.length - 1}` : ""} +${esc(i.hotline)}${esc(i.contract_number)}${esc(i.note)} +${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}
`).join(""); + return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))} +
${searchBox(q, "Поиск: оператор, организация, IP", 300)}
+
НазваниеОрганизацияIP-адресаГорячая линияДоговорЗаметки
+${rows || '
Ничего не найдено
'}
Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}
`); +}; +function ispDialog(i) { + const edit = !!i; + S.dialog = async (v) => { + const body = { ...v, organization_id: Number(v.organization_id), networks: v.networks.split(/\s+/).filter(Boolean) }; + await api(edit ? `/isps/${i.id}` : "/isps", { method: edit ? "PUT" : "POST", body }); + toast(edit ? "Оператор сохранён" : "Оператор добавлен"); + await draw(); + }; + openDialog({ + title: edit ? "Редактирование оператора" : "Новый оператор связи", + body: formBody(`${fInput("name", "Название", { value: i?.name })} +${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), i?.organization_id ?? store.orgId)} +${fArea("networks", "IP-адреса", { value: (i?.networks || []).join("\n"), ph: "один CIDR на строку", rows: 3, h: 72, mono: true, optional: false, hint: "можно несколько" })} +
${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}
+${fArea("note", "Примечание", { value: i?.note })}`), + foot: dlgFoot(edit ? "Сохранить" : "Добавить оператора"), + }); +} + +// ---- devices +screens.devices = async () => { + const org = currentOrg(); + const [types, list] = await Promise.all([ + api("/device-types"), + api("/devices", { params: { organization_id: org?.id, device_type_id: S.type, q: S.q, limit: 500 } }), + ]); + S.types = types.items; + S.rows = list.items; + const cols = "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px"; + const q = S.q || ""; + const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))]; + const rows = list.items.map((d, n) => { + const dep = d.all_deprecated; + const ips = d.ip_addresses.length + ? `${d.ip_addresses.length}${d.ip_addresses.length > 1 ? `+${d.ip_addresses.length - 1}` : ""}` + : `0`; + return `
${esc(d.name)} +${esc(d.device_type_name)}${ips}${esc(d.note)} +${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}
`; + }).join(""); + return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))} + +
${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}
+
УстройствоТипIP-адресовЗаметки
+${rows || '
Устройств нет
'}
Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}
`); +}; +function deviceDialog(d) { + const edit = !!d; + S.dialog = async (v) => { + const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note } + : { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) }; + await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body }); + toast(edit ? "Устройство сохранено" : "Устройство добавлено"); + await draw(); + }; + openDialog({ + title: edit ? "Редактирование устройства" : "Новое устройство", + body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })} +
${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}
+${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)} +${fArea("note", "Заметки", { value: d?.note })} +
IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».
`), + foot: dlgFoot(edit ? "Сохранить" : "Добавить устройство"), + }); +} +async function typesDialog() { + const { items } = await api("/device-types"); + S.types = items; + const cols = "1fr 110px 84px"; + const rows = items.map((t) => { + const editing = S.typeEdit === t.id; + const name = editing + ? `` + : `${esc(t.name)}`; + const locked = t.devices_count > 0; + const acts = t.is_default + ? `по умолчанию` + : editing + ? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}` + : `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`; + return `
${name}${t.devices_count}${acts}
`; + }).join(""); + openDialog({ + title: "Управление типами устройств", width: 640, + note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", + body: `
НазваниеУстройств
${rows}
+
`, + foot: `
${btn("Готово", "close-dialog", { cls: "primary" })}
`, + }); +} + +// ---- prefixes +screens.prefixes = async () => { + const org = currentOrg(); + if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `
Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}
`); + const [pl, vl] = await Promise.all([ + api("/prefixes", { params: { organization_id: org.id, limit: 1000 } }), + api("/vrfs", { params: { organization_id: org.id } }), + ]); + S.prefixes = pl.items; + S.vrfs = vl.items; + const all = pl.items; + const q = (S.q || "").toLowerCase(); + const filtered = all.filter((p) => (!S.vrf || p.vrf_id === S.vrf) && (!S.status || p.status === S.status) && (!S.family || p.family === Number(S.family)) + && (!q || p.prefix.includes(q) || p.description.toLowerCase().includes(q))); + const hasKids = new Set(all.map((p) => p.parent_id).filter(Boolean)); + if (!S.collapsed) S.collapsed = new Set(all.filter((p) => !p.depth && hasKids.has(p.id)).slice(1).map((p) => p.id)); // раскрыта только первая ветка + const collapsed = S.collapsed; + const byId = Object.fromEntries(all.map((p) => [p.id, p])); + const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; }; + const visible = filtered.filter((p) => !hidden(p)); + const cols = "44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px"; + const tab = (id, label, n) => ``; + const rows = visible.map((p, n) => { + const [bar, txt] = utilColor(p.utilization); + const [sc, st] = PREFIX_STATUS[p.status]; + const leaf = !hasKids.has(p.id); + const chev = hasKids.has(p.id) ? `` : ""; + const pad = p.depth * 20; + const cidr = leaf ? `${esc(p.prefix)}` : `${esc(p.prefix)}`; + return `
${chev}${cidr}${esc(p.description)}${esc(p.vrf_name)}${badge(sc, st)} +
${p.utilization}%
${fmtNum(p.used)} +${leaf ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }, { label: "Удалить", value: "del:" + p.id, cls: "danger" }], "row-pop") : ""}
`; + }).join(""); + const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }]; + const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }]; + return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))} +
${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}
+
${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}
+
ПрефиксОписаниеVRFСтатусИспользованиеАдресов
+${rows || '
Префиксов нет
'}
Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}
`); +}; +function prefixDialog(p) { + const edit = !!p; + S.dialog = async (v) => { + if (edit) await api(`/prefixes/${p.id}`, { method: "PATCH", body: { vrf_id: Number(v.vrf_id), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } }); + else await api("/prefixes", { method: "POST", body: { ...v, organization_id: store.orgId, vrf_id: Number(v.vrf_id), parent_id: v.parent_id ? Number(v.parent_id) : null } }); + toast(edit ? "Префикс сохранён" : "Префикс добавлен"); + await draw(); + }; + const parents = (S.prefixes || []).map((x) => ({ value: x.id, label: x.description ? `${x.prefix} — ${x.description}` : x.prefix })); + // число вложенных префиксов: они переезжают вместе с выбранным при смене VRF + let nested = 0; + if (edit) { + let level = [p.id]; + while (level.length) { level = (S.prefixes || []).filter((x) => level.includes(x.parent_id)).map((x) => x.id); nested += level.length; } + S.vrfHint = { original: String(p.vrf_id) }; + } + openDialog({ + title: edit ? `Префикс ${p.prefix}` : "Новый префикс", + body: formBody(`${edit ? "" : fInput("prefix", "Префикс (CIDR)", { ph: "0.0.0.0/0", mono: true, hint: "например 10.30.0.0/24" }).replace("${fSelect("vrf_id", "VRF", (S.vrfs || []).map((v) => ({ value: v.id, label: v.name })), edit ? p.vrf_id : S.vrf || S.vrfs?.[0]?.id)}${fSelect("status", "Статус", [["active", "Активен"], ["reserved", "Резерв"], ["deprecated", "Устарел"]].map(([value, label]) => ({ value, label })), p?.status ?? "active")} +${edit && nested ? `` : ""} +${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })} +${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })} +`), + foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"), + }); +} +async function vrfsDialog() { + const org = currentOrg(); + const { items } = await api("/vrfs", { params: { organization_id: org.id } }); + S.vrfs = items; + const cols = "1fr 140px 90px 84px"; + const rows = items.map((v) => { + const editing = S.vrfEdit === v.id; + const acts = editing + ? `${iconBtn(I.edit(14), "vrf-save", "Сохранить", `data-id="${v.id}"`, true)}${iconBtn(I.close(), "vrf-cancel", "Отмена", "", true)}` + : `${iconBtn(I.edit(14), "vrf-edit", "Переименовать", `data-id="${v.id}"`, true)}${iconBtn(I.trash(14), "vrf-del", v.prefixes_count ? "Нельзя удалить: используется префиксами" : "Удалить", `data-id="${v.id}"`, true, v.prefixes_count > 0)}`; + return `
${editing ? `` : `${esc(v.name)}`} +${editing ? `` : `${esc(v.route_target)}`}${v.prefixes_count}${acts}
`; + }).join(""); + openDialog({ + title: "Управление VRF", width: 680, + note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`, + body: `
НазваниеRoute targetПрефиксов
${rows}
+
`, + foot: `
${btn("Готово", "vrf-done", { cls: "primary" })}
`, + }); +} + +// ---- addresses of one prefix +screens.address = async (id) => { + const p = await api(`/prefixes/${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", { params: { organization_id: p.organization_id, limit: 1000 } }), + 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; + 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; + const cols = "minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px"; + const rows = page.items.map((a, n) => { + const [c, t] = ADDR_STATUS[a.status]; + const free = a.status === "free"; + const key = "adr-row-" + (a.id ?? a.address); + const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }]; + return `
${esc(a.address)} +${esc(a.dns_name || "—")}${esc(a.description || (free ? "—" : ""))}${badge(c, t)} +${a.updated_at ? fmtDate(a.updated_at) : "—"}${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}
`; + }).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` : ""; + const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }]; + return shell("prefixes", `
Префиксы${chain.map((x) => `${I.chevR(14)}${esc(x.prefix)}`).join("")}${I.chevR(14)}${esc(p.prefix)}
+

${esc(p.prefix)}

${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}
+
${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}
+
+
Назначено ${sm.assigned}Зарезервировано ${sm.reserved}Свободно ${sm.free}
+
${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}
+
IP-адресDNS-имяОписаниеСтатусИзменён (UTC)
+${rows || '
Адресов нет
'}
Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}
`); +}; +function addressDialog(a, presetIp = "") { + const edit = !!a; + const p = S.prefix; + S.dialog = async (v) => { + const body = { status: v.status, dns_name: v.dns_name, description: v.description, device_id: v.device_id ? Number(v.device_id) : null, note: v.note }; + if (edit) await api(`/addresses/${a.id}`, { method: "PATCH", body }); + else await api(`/prefixes/${p.id}/addresses`, { method: "POST", body: { ...body, address: v.address } }); + toast(edit ? "Адрес сохранён" : "Адрес назначен"); + await draw(); + }; + const ipField = edit + ? `` + : fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") }); + openDialog({ + title: edit ? "Редактировать адрес" : "Назначить адрес", + body: formBody(`
Подсеть: ${esc(p.prefix)} · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}
+
${ipField}${fSelect("status", "Статус", [["assigned", "Назначен"], ["reserved", "Резерв"], ["deprecated", "Устаревший"]].map(([value, label]) => ({ value, label })), a?.status ?? "assigned")}
+${fInput("dns_name", "DNS-имя", { value: a?.dns_name, mono: true, ph: "host.example.com", optional: true }).replace("необязательно", "необязательно, FQDN")} +${fInput("description", "Описание", { value: a?.description })} +
${fSelect("device_id", "Связано с устройством", S.devices.map((d) => ({ value: d.id, label: d.name })), a?.device_id ?? "", { optional: true, empty: "Выберите устройство…" }).replace('
+${fArea("note", "Примечание", { value: a?.note })}`), + foot: dlgFoot(edit ? "Сохранить" : "Назначить адрес"), + }); +} + +// ---- journal (audit) +const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", session: "Сессия", journal: "Журнал" }; +const eventBadge = (ev) => { + const [entity, action] = ev.split("."); + const cls = { created: "blue", assigned: "green", updated: "amber", deleted: "red", failed: "red" }[action] || ""; + return badge(cls, ev); +}; +const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—"); +const isAdmin = () => user?.role === "admin"; + +screens.journal = async () => { + const limit = S.limit || 100; + const [page, sum, facets] = await Promise.all([ + api("/audit", { params: { event_type: S.jtype, actor: S.jactor, entity_type: S.jentity, date_from: S.jfrom, date_to: S.jto, q: S.q, limit } }), + api("/audit/summary"), + api("/audit/facets"), + ]); + S.entries = page.items; S.summary = sum; + const any = (l) => ({ label: l, value: "" }); + const typeItems = [any("любой"), ...facets.event_types.map((v) => ({ label: v, value: v }))]; + const actorItems = [any("любой"), ...facets.actors.map((v) => ({ label: v, value: v }))]; + const entItems = [any("любая"), ...facets.entity_types.map((v) => ({ label: ENTITY_RU[v] || v, value: v }))]; + const cols = "150px 168px 150px 1fr 110px 130px 96px"; + const rows = page.items.map((r) => `
+${fmtDateSec(r.ts)}${eventBadge(r.event_type)} +${esc(ENTITY_RU[r.entity_type] || r.entity_type)}${["session", "journal"].includes(r.entity_type) ? "" : `${esc(r.entity_label)}`} +${esc(r.message)}${esc(r.actor)}${esc(r.client_ip || "—")}${r.short_id}
`).join(""); + const n = sum.total; + const rot = `ротация: ${sum.retention_days ? sum.retention_days + " " + plural(sum.retention_days, "день", "дня", "дней") : "без ограничения по сроку"} / ${sum.max_entries ? fmtNum(sum.max_entries) + " записей" : "без ограничения по количеству"}`; + const act = btn("Обновить", "jr-refresh", { icon: I.refresh() }) + (isAdmin() ? btn("Настройки", "jr-settings", { icon: I.cog() }) + btn("Очистить журнал", "jr-clear", { cls: "danger", icon: I.trashJ() }) : ""); + const dateBox = (id, label, v) => ``; + return shell("journal", `${header("Журнал", `${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sum.oldest_ts ? fmtDate(sum.oldest_ts) : "—"} · ${rot}`, act)} +
${filterBtn("jtype", "Тип", typeItems, S.jtype ?? "", 170)}${filterBtn("jactor", "Актор", actorItems, S.jactor ?? "", 170)}${filterBtn("jentity", "Сущность", entItems, S.jentity ?? "", 200)}${dateBox("jfrom", "с", S.jfrom)}${dateBox("jto", "по", S.jto)}${searchBox(S.q || "", "Сообщение, ID или IP", 240)}
+
Время (UTC)ТипСущностьСообщениеАкторIP-адресID записи
+${rows || '
Записей нет
'} +${page.total > page.items.length ? `
${btn("Показать ещё 100", "more")}
` : ""} +
Показано ${page.items.length} из ${fmtNum(page.total)} ${plural(page.total, "записи", "записей", "записей")}
`); +}; + +async function copyText(text) { + try { await navigator.clipboard.writeText(text); } catch { + const ta = document.createElement("textarea"); // по http navigator.clipboard недоступен + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.append(ta); ta.select(); + try { document.execCommand("copy"); } finally { ta.remove(); } + } + toast("Скопировано"); +} +const copyBtn = (text) => iconBtn(I.copy(), "copy", "Копировать", `data-text="${esc(text)}"`); +function entryDialog(r) { + const row = (label, inner) => `
${label}
${inner}
`; + const mono = (v) => `${esc(v)}`; + const entity = ["session", "journal"].includes(r.entity_type) ? esc(ENTITY_RU[r.entity_type]) : `${esc(ENTITY_RU[r.entity_type] || r.entity_type)} · ${mono(r.entity_label)}`; + openDialog({ + title: "Запись журнала", width: 640, + body: `
${row("ID записи", mono("evt_" + r.uid) + copyBtn("evt_" + r.uid))} +${row("Время", fmtDateSec(r.ts) + " UTC")}${row("Тип", eventBadge(r.event_type))}${row("Актор", esc(r.actor))} +${row("IP-адрес", r.client_ip ? mono(r.client_ip) + copyBtn(r.client_ip) : '—')}${row("Сущность", entity)}${row("Сообщение", esc(r.message))} +${r.meta?.method ? row("Запрос", mono(`${r.meta.method} ${r.meta.path}`)) : ""}${r.meta?.user_agent ? row("User-Agent", `${esc(r.meta.user_agent)}`) : ""} +
Данные
${r.diff ? esc(JSON.stringify(r.diff, null, 2)) : "—"}
`, + foot: `
${btn("Закрыть", "close-dialog")}
`, + }); +} +function journalSettingsDialog() { + const sm = S.summary; + const n = sm.total; + S.dialog = async (v) => { + const res = await api("/journal/settings", { method: "PUT", body: { retention_days: Number(v.retention_days), max_entries: Number(v.max_entries) } }); + toast(res.deleted ? `Настройки сохранены, удалено записей: ${res.deleted}` : "Настройки сохранены"); + S.limit = 100; await draw(); + }; + openDialog({ + title: "Настройки журнала", + body: formBody(`
Сейчас в журнале: ${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sm.oldest_ts ? fmtDate(sm.oldest_ts) : "—"}
+${fInput("retention_days", "Хранить записи, дней", { value: sm.retention_days, type: "number", hint: "0 — без ограничения по сроку" }).replace("Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.`), + foot: dlgFoot("Сохранить"), + }); +} +function journalClearDialog(state = {}) { + const n = S.summary.total; + const locked = !!state.retry; + const minutes = state.retry ? Math.max(1, Math.ceil(state.retry / 60)) : 0; + openDialog({ + title: "Очистить журнал", width: 540, + body: `
Будет удалено ${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.
+ +${state.wrong ? `
Неверный пароль. Осталось попыток: ${state.left}
` : ""} +${locked ? `
Слишком много неверных попыток. Повторите через ${minutes} мин.
` : ""}
`, + foot: `
${btn("Отмена", "close-dialog", { cls: "ghost" })}
`, + }); + $("#clear-pw")?.focus(); +} +async function doClear() { + const pw = $("#clear-pw").value; + if (!pw) return; + try { + const res = await api("/journal/clear", { method: "POST", body: { password: pw } }); + closeDialog(); toast(`Журнал очищен, удалено записей: ${res.deleted}`); S.limit = 100; await draw(); + } catch (e) { + if (!(e instanceof ApiError)) throw e; + if (e.status === 429) journalClearDialog({ retry: e.body.retry_after_seconds }); + else if (e.status === 403) { journalClearDialog({ wrong: true, left: e.body.attempts_left }); } + else toast(e.message, true); + } +} + +// ---- login +function loginScreen(err = "") { + return ``; +} + +/* ------------------------------------------------------------------- routing */ +async function loadOrgs() { + orgs = (await api("/organizations", { params: { limit: 500 } })).items; + if (!orgs.some((o) => o.id === store.orgId) && orgs.length) store.orgId = orgs[0].id; +} +function route() { + const [path, ...rest] = (location.hash.replace(/^#\/?/, "") || "overview").split("/"); + return { path, arg: rest[0] }; +} +let current = ""; +async function draw() { + const { path, arg } = route(); + const app = $("#app"); + if (path === "login" || !store.token) { + if (path !== "login") { location.hash = "#/login"; return; } + app.innerHTML = loginScreen(); + return; + } + const key = path + "/" + (arg || ""); + if (key !== current) { S = {}; current = key; menu = null; closeDialog(); } + try { + if (!user) { user = await api("/auth/me"); await loadOrgs(); } + const fn = path === "prefixes" && arg ? () => screens.address(Number(arg)) : screens[path] || screens.overview; + const active = document.activeElement; + const focusId = active?.id, caret = active?.selectionStart; + app.innerHTML = await fn(); + if (focusId) { const el = document.getElementById(focusId); if (el) { el.focus(); try { el.setSelectionRange(caret, caret); } catch { /* ignore */ } } } + } catch (e) { + if (e instanceof ApiError && e.status === 401) return; + app.innerHTML = shell(path, `
Не удалось загрузить данные: ${esc(e.message)}
`); + } +} +window.addEventListener("hashchange", draw); + +/* --------------------------------------------------------------------- events */ +const confirmDel = (what) => confirm(`Удалить ${what}?`); +async function guarded(fn) { + try { await fn(); } catch (e) { if (e instanceof ApiError) toast(e.message, true); else throw e; } +} +const rowById = (id) => S.rows?.find((r) => r.id === Number(id)); + +const rowActions = { + "org-row": async (act, id) => { + if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; } + else if (act === "edit") orgDialog(rowById(id)); + else if (act === "del" && confirmDel("организацию")) await guarded(async () => { await api(`/organizations/${id}`, { method: "DELETE" }); await loadOrgs(); toast("Организация удалена"); await draw(); }); + }, + "isp-row": async (act, id) => { + if (act === "edit") ispDialog(rowById(id)); + else if (act === "del" && confirmDel("оператора")) await guarded(async () => { await api(`/isps/${id}`, { method: "DELETE" }); toast("Оператор удалён"); await draw(); }); + }, + "dev-row": async (act, id) => { + if (act === "edit") deviceDialog(rowById(id)); + else if (act === "del" && confirmDel("устройство")) await guarded(async () => { await api(`/devices/${id}`, { method: "DELETE" }); toast("Устройство удалено"); await draw(); }); + }, + "pfx-row": async (act, id) => { + if (act === "open") location.hash = `#/prefixes/${id}`; + else if (act === "edit") prefixDialog(S.prefixes.find((p) => p.id === Number(id))); + else if (act === "del" && confirmDel("префикс")) await guarded(async () => { + try { await api(`/prefixes/${id}`, { method: "DELETE" }); } catch (e) { + if (e.status === 409 && confirm(`${e.message}. Удалить вместе с адресами?`)) await api(`/prefixes/${id}?force=true`, { method: "DELETE" }); else throw e; + } + toast("Префикс удалён"); await draw(); + }); + }, + "adr-row": async (act, id) => { + if (act === "assign") addressDialog(null, id); + else if (act === "edit") addressDialog(S.page.items.find((a) => a.id === Number(id))); + else if (act === "del" && confirmDel("адрес")) await guarded(async () => { await api(`/addresses/${id}`, { method: "DELETE" }); toast("Адрес удалён"); await draw(); }); + }, +}; + +const actions = { + logout: () => { store.token = null; user = null; current = ""; location.hash = "#/login"; draw(); }, + menu: (d, el) => { menu = menu?.id === d.menu ? null : { id: d.menu }; draw(); }, + pick: async (d) => { + const { menu: id, value } = d; + menu = null; + if (id === "org") { store.orgId = Number(value); S.vrf = 0; S.collapsed = null; } + 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 === "jtype") { S.jtype = value; S.limit = 100; } + else if (id === "jactor") { S.jactor = value; S.limit = 100; } + else if (id === "jentity") { S.jentity = value; S.limit = 100; } + else { + const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-")); + const [act, rid] = value.split(":"); + if (key) { document.querySelectorAll(".pop").forEach((el) => el.remove()); return rowActions[key](act, rid); } + } + draw(); + }, + "vrf-tab": (d) => { S.vrf = Number(d.id); draw(); }, + toggle: (d) => { const c = (S.collapsed ||= new Set()); c.has(Number(d.id)) ? c.delete(Number(d.id)) : c.add(Number(d.id)); draw(); }, + more: () => { S.limit = (S.limit || 100) + 100; draw(); }, + "dd-toggle": (d, el) => { + const pop = el.parentElement.querySelector(".dd"); + const open = pop.hidden; + closeDropdowns(); + pop.hidden = !open; + if (open) (pop.querySelector(".sel") || pop.querySelector("button"))?.focus(); + }, + "dd-pick": (d, el) => { + const box = el.closest(".cselect"), input = box.querySelector("input"); + input.value = d.value; + box.querySelector(".v").textContent = el.textContent; + box.querySelector(".select").classList.toggle("ph", d.empty === "1"); + box.querySelectorAll(".dd button").forEach((b) => b.classList.toggle("sel", b === el)); + closeDropdowns(); + box.querySelector(".select").focus(); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, + "pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; }, + "org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; }, + "isp-edit": (d) => ispDialog(rowById(d.id)), + "dev-edit": (d) => deviceDialog(rowById(d.id)), + "adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))), + "adr-assign": (d) => addressDialog(null, d.ip), + "jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)), + "jr-refresh": () => draw(), + "jr-settings": () => journalSettingsDialog(), + "jr-clear": () => journalClearDialog(), + "jr-clear-do": doClear, + copy: (d) => copyText(d.text), + "close-dialog": closeDialog, + overlay: (d, el, e) => { if (e.target === el) closeDialog(); }, + "submit-form": submitDialog, + "open-org": (d) => { store.orgId = Number(d.id); }, + "org-new": () => orgDialog(null), + "isp-new": () => ispDialog(null), + "dev-new": () => deviceDialog(null), + "pfx-new": () => prefixDialog(null), + "pfx-edit": () => prefixDialog(S.prefix), + "adr-new": () => addressDialog(null), + types: () => typesDialog(), + vrfs: () => vrfsDialog(), + "vrf-done": async () => { closeDialog(); S.vrfEdit = null; await draw(); }, + "vrf-add": () => guarded(async () => { + const name = $("#new-vrf").value.trim(); + if (!name) return toast("Введите название VRF", true); + await api("/vrfs", { method: "POST", body: { organization_id: store.orgId, name, route_target: $("#new-rt").value.trim() } }); + await vrfsDialog(); + }), + "vrf-edit": (d) => { S.vrfEdit = Number(d.id); vrfsDialog(); }, + "vrf-cancel": () => { S.vrfEdit = null; vrfsDialog(); }, + "vrf-save": (d) => guarded(async () => { + await api(`/vrfs/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim(), route_target: $("#edit-rt").value.trim() } }); + S.vrfEdit = null; await vrfsDialog(); + }), + "vrf-del": (d) => confirmDel("VRF") && guarded(async () => { await api(`/vrfs/${d.id}`, { method: "DELETE" }); await vrfsDialog(); }), + "type-add": () => guarded(async () => { + const name = $("#new-type").value.trim(); + if (!name) return toast("Введите название типа", true); + await api("/device-types", { method: "POST", body: { name } }); + await typesDialog(); + }), + "type-edit": (d) => { S.typeEdit = Number(d.id); typesDialog(); }, + "type-cancel": () => { S.typeEdit = null; typesDialog(); }, + "type-save": (d) => guarded(async () => { + await api(`/device-types/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim() } }); + S.typeEdit = null; await typesDialog(); + }), + "type-del": (d) => confirmDel("тип") && guarded(async () => { await api(`/device-types/${d.id}`, { method: "DELETE" }); await typesDialog(); }), +}; + +const closeDropdowns = () => document.querySelectorAll(".dd:not([hidden])").forEach((p) => { p.hidden = true; }); +document.addEventListener("click", (e) => { + if (!e.target.closest(".cselect")) closeDropdowns(); + const el = e.target.closest("[data-action]"); + if (!el) { + if (menu) { menu = null; draw(); } + return; + } + const a = el.dataset.action; + if (el.dataset.row) { + // действие всей строки: не мешаем ссылкам, кнопкам, меню, Ctrl/Shift-клику и выделению текста + if (e.target.closest("a[href], button, input, label, select, textarea, .pop")) return; + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || String(window.getSelection?.() ?? "")) return; + if (menu) { menu = null; draw(); return; } // открытое меню: клик лишь закрывает его + } + if (menu && a !== "menu" && a !== "pick") { menu = null; if (!actions[a]) draw(); } + if (el.tagName === "A" && el.getAttribute("href") === "#") e.preventDefault(); + if (actions[a]) actions[a](el.dataset, el, e); +}); +let timer; +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); +}); +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; } + const hint = $("#vrf-hint"); + if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original; +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && document.querySelector(".dd:not([hidden])")) { closeDropdowns(); e.stopPropagation(); return; } + if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target.closest(".cselect")) { + e.preventDefault(); + const box = e.target.closest(".cselect"), pop = box.querySelector(".dd"); + if (pop.hidden) { closeDropdowns(); pop.hidden = false; (pop.querySelector(".sel") || pop.querySelector("button")).focus(); return; } + const items = [...pop.querySelectorAll("button")], i = items.indexOf(document.activeElement); + items[Math.max(0, Math.min(items.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))].focus(); + return; + } + if (e.key === "Escape") { if ($("#modal-root").innerHTML) closeDialog(); else if (menu) { menu = null; draw(); } } + if (e.key === "Enter" && e.target.id === "clear-pw") { e.preventDefault(); doClear(); return; } + if (e.key === "Enter" && e.target.dataset?.action === "jr-open") { e.preventDefault(); actions["jr-open"](e.target.dataset); return; } + if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); } +}); +document.addEventListener("submit", async (e) => { + if (e.target.id !== "login-form") return; + e.preventDefault(); + const f = new FormData(e.target); + try { + const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } }); + store.token = access_token; + user = null; current = ""; + location.hash = "#/overview"; + draw(); + } catch (err) { + $("#app").innerHTML = loginScreen(err.message); + } +}); + +draw(); diff --git a/web/fonts/OFL.txt b/web/fonts/OFL.txt new file mode 100644 index 0000000..2ae8ef3 --- /dev/null +++ b/web/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/fonts/fonts.css b/web/fonts/fonts.css new file mode 100644 index 0000000..2e1facb --- /dev/null +++ b/web/fonts/fonts.css @@ -0,0 +1,11 @@ +/* IBM Plex (SIL OFL 1.1, см. OFL.txt) — локальные файлы, без обращения к внешним сервисам */ +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-sans-cyrillic-400.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-sans-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-sans-cyrillic-500.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-sans-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(ibm-plex-sans-cyrillic-600.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(ibm-plex-sans-latin-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-mono-cyrillic-400.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116} +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-mono-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-mono-cyrillic-500.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116} +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-mono-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} diff --git a/web/fonts/ibm-plex-mono-cyrillic-400.woff2 b/web/fonts/ibm-plex-mono-cyrillic-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..20e72b01579543fe0cb4aed955a146b6b949709f GIT binary patch literal 8356 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 literal 0 HcmV?d00001 diff --git a/web/fonts/ibm-plex-sans-cyrillic-500.woff2 b/web/fonts/ibm-plex-sans-cyrillic-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..189f9dcfebb44722406f2c8e7e06e6f194154ab9 GIT binary patch literal 15000 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 literal 0 HcmV?d00001 diff --git a/web/fonts/ibm-plex-sans-cyrillic-600.woff2 b/web/fonts/ibm-plex-sans-cyrillic-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2c315c4771fababf16526d5190cefa26c4e85f7c GIT binary patch literal 15040 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 W!UW;Hvt2PP?%PobSYccN0000gCRuv` literal 0 HcmV?d00001 diff --git a/web/fonts/ibm-plex-sans-latin-400.woff2 b/web/fonts/ibm-plex-sans-latin-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f0ee65dd7f3ed64dc8d674e5c6007a0e7761bd57 GIT binary patch literal 22588 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 R;$T5nL-p?z%LdxSxC2frlW_n5 literal 0 HcmV?d00001 diff --git a/web/fonts/ibm-plex-sans-latin-500.woff2 b/web/fonts/ibm-plex-sans-latin-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6d5527eef47ace16b08cf9932050b83858995e62 GIT binary patch literal 24184 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 + + + + +IPAM Manager + + + + +

+ +
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..8d3c389 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,184 @@ +:root{--bg:#f4f6f9;--card:#fff;--line:#dde3ea;--line-strong:#c5ced9;--text:#18212f;--muted:#58657a;--faint:#8a95a5;--primary:#2450c8;--head:#f8fafc;--hover:#f3f7ff; + --sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,monospace} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);font-family:var(--sans);color:var(--text)} +a{color:var(--primary);text-decoration:none} +h1,h2{margin:0} +button{font-family:var(--sans)} + +/* app bar */ +.appbar{height:56px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 24px;gap:32px} +.brand{display:flex;align-items:center;gap:10px;font:600 16px/1 var(--sans)} +.brand-logo{width:28px;height:28px;border-radius:8px;background:var(--primary);display:flex;align-items:center;justify-content:center} +.nav{display:flex;height:100%;gap:4px} +.nav a{display:flex;align-items:center;height:100%;padding:0 14px;font:500 14px/1 var(--sans);color:var(--muted);border-bottom:2px solid transparent} +.nav a.active{color:var(--text);border-bottom-color:var(--primary)} +.grow{flex:1} +.appbar .user{color:var(--muted);font:400 14px/1 var(--sans)} + +/* buttons */ +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:36px;margin:0;border-radius:8px;font:500 14px/1 var(--sans);white-space:nowrap;cursor:pointer;padding:0 14px;background:#fff;color:var(--text);border:1px solid var(--line-strong)} +.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)} +.btn.outline-primary{background:#fff;color:var(--primary);border-color:var(--primary)} +.btn.ghost{background:transparent;color:var(--muted);border-color:transparent;padding:0 10px} +.btn.danger{color:#a32020} +.btn:disabled{opacity:.6;cursor:default} +.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;margin:0;border-radius:8px;cursor:pointer;background:transparent;color:var(--muted);border:1px solid transparent;padding:0} +.icon-btn:hover{background:#eef1f5} +.icon-btn.sm{width:28px;height:28px;border-radius:6px} +.icon-btn:disabled{color:var(--line-strong);cursor:default;background:transparent} +svg{flex:none} + +/* layout */ +.page{padding:0 24px} +.crumbs{display:flex;align-items:center;gap:6px;padding:16px 0 0;font:500 13px/1 var(--sans);color:var(--muted)} +.crumbs .cur{font-weight:600;color:var(--text)} +.head{display:flex;align-items:flex-end;justify-content:space-between;margin:24px 0 16px} +.crumbs + .head{margin:8px 0 16px} +.head h1{font:600 24px/1.2 var(--sans);color:var(--text)} +.head .sub{margin-top:4px;font:400 14px/1.4 var(--sans);color:var(--muted)} +.head .actions{display:flex;gap:8px} +.card{background:#fff;border:1px solid var(--line);border-radius:12px} +.right-link{display:flex;justify-content:flex-end;margin-bottom:12px} +.right-link a,.link-muted{display:flex;align-items:center;gap:6px;font:500 13px/1 var(--sans);color:var(--muted);cursor:pointer;background:none;border:0;padding:0} + +/* tabs */ +.tabs{display:flex;align-items:center;gap:24px;border-bottom:1px solid var(--line);margin-bottom:16px} +.tab{padding:10px 2px 12px;margin-bottom:-1px;font:500 14px/1 var(--sans);color:var(--muted);border-bottom:2px solid transparent;cursor:pointer;background:none;border-top:0;border-left:0;border-right:0} +.tab.active{font-weight:600;color:var(--text);border-bottom-color:var(--primary)} +.tab .n{font-weight:400;color:var(--muted);margin-left:2px} +.tabs .link-muted{padding:10px 2px 12px;margin-bottom:-1px} + +/* filters */ +.filters{display:flex;align-items:center;gap:8px;height:64px;padding:0 16px;position:relative} +.vsep{width:1px;height:24px;background:var(--line);flex:none} +.search{position:relative;display:inline-flex;align-items:center;flex:none} +.search .ico{position:absolute;left:12px;color:var(--muted);display:flex} +.search input{width:100%;height:36px;padding:0 12px 0 36px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans);margin:0} +.filter-btn{justify-content:space-between;width:180px} +.org-btn{display:inline-flex;align-items:center;gap:8px;height:36px;border-radius:8px;font:500 14px/1 var(--sans);cursor:pointer;background:#f3f7ff;color:var(--text);border:1px solid var(--primary);padding:0 12px;flex:none;min-width:260px} +.org-btn .lbl{color:var(--muted);font-weight:400} +.org-btn .val{font-weight:600;flex:1;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.pop{position:absolute;top:52px;z-index:20;min-width:220px;max-height:320px;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(20,33,60,.16);padding:4px} +.pop button{display:block;width:100%;text-align:left;height:36px;padding:0 12px;border:0;background:none;border-radius:6px;font:400 14px/1 var(--sans);color:var(--text);cursor:pointer;white-space:nowrap} +.pop button:hover{background:var(--hover)} +.pop button.sel{font-weight:600} +.pop button.danger{color:#a32020} +.row-pop{top:40px;right:0;min-width:180px} +.rel{position:relative} + +/* table */ +.tr{display:grid;grid-template-columns:var(--cols);column-gap:12px;align-items:center;height:var(--h,56px);padding:0 16px;border-bottom:1px solid var(--line);font:400 14px/1.3 var(--sans)} +.tr.th{height:40px;background:var(--head);border-top:1px solid var(--line);color:var(--muted)} +.tr.th span{font:600 12px/1 var(--sans);color:var(--muted)} +.tr.hoverable:hover{background:var(--hover)} +.tr.clickable{cursor:pointer} +.tr.child{background:#fbfcfd} +.tr.free{background:var(--head)} +.tr.free span,.tr.free .mono{color:var(--faint)} +.tr.last{border-bottom:0} +.foot{display:flex;align-items:center;height:44px;padding:0 16px;font:400 13px/1 var(--sans);color:var(--muted)} +.foot a{margin-left:4px;cursor:pointer} +.empty{padding:32px 16px;text-align:center;color:var(--muted);font-size:14px} +.mono{font-family:var(--mono)} +.m13{font:500 13px/1 var(--mono)} +.muted{color:var(--muted)} +.ell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cell-actions{display:flex;justify-content:flex-end} +.chev{display:flex;justify-content:center} +.chev button{background:none;border:0;cursor:pointer;color:var(--muted);display:flex;padding:0} +.chev svg{transition:transform .12s} +.chev.open svg{transform:rotate(90deg)} +.bar{display:flex;align-items:center;gap:8px} +.bar .track{flex:1;height:4px;border-radius:2px;background:var(--line)} +.bar .fill{height:100%;border-radius:2px} +.bar .pct{font:400 12px/1 var(--sans);color:var(--muted);white-space:nowrap} + +/* badges */ +.badge{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border-radius:6px;font:500 12px/1 var(--sans);background:#eef1f5;color:#3c4859} +.badge.green{background:#e3f4ea;color:#14683a} +.badge.blue{background:#eaf0ff;color:#1e449e} +.badge.red{background:#fbe6e6;color:#a32020} +.badge.amber{background:#fdf0d5;color:#7c4a00} +.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:#eaf0ff;color:#1e449e;font:500 11px/1 var(--sans)} + +/* overview */ +.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px} +.stat{padding:20px 24px} +.stat .l{font:400 13px/1.3 var(--sans);color:var(--muted);margin-bottom:8px} +.stat .v{font:600 32px/1 var(--sans)} +.stat .s{margin-top:6px;font:400 13px/1.3 var(--sans);color:var(--muted)} +.stat .track{margin-top:8px;height:6px;border-radius:3px;background:var(--line)} +.stat .track div{height:100%;border-radius:3px;background:var(--primary)} +.two{display:grid;grid-template-columns:1fr 1fr;gap:16px} +.card-title{padding:16px 16px 12px;font:600 16px/1.2 var(--sans)} + +/* address usage */ +.usage{padding:16px;margin-bottom:16px;display:flex;align-items:center;gap:16px} +.usage .track{flex:1;height:8px;border-radius:4px;background:var(--line);overflow:hidden;display:flex} +.legend{display:flex;gap:20px;font:400 13px/1 var(--sans)} +.legend span{display:flex;align-items:center;gap:6px} +.legend i{width:8px;height:8px;border-radius:2px;display:inline-block} + +/* dialogs */ +.overlay{position:fixed;inset:0;background:rgba(24,33,47,.45);display:flex;align-items:flex-start;justify-content:center;padding-top:96px;overflow:auto;z-index:50} +.dialog{box-sizing:content-box;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 48px rgba(20,33,60,.25);flex:none;margin-bottom:40px} +.dialog-head{display:flex;align-items:center;justify-content:space-between;padding:16px 16px 0 24px} +.dialog-head h2{font:600 18px/1.2 var(--sans)} +.dialog-note{padding:4px 24px 0;font:400 13px/1.4 var(--sans);color:var(--muted)} +.dialog-body{display:flex;flex-direction:column;gap:16px;padding:16px 24px 24px} +.dialog-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:16px 24px;border-top:1px solid var(--line)} +.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.field{display:flex;flex-direction:column;gap:6px;font:500 13px/1.3 var(--sans)} +.field .opt{font-weight:400;color:var(--muted)} +.input,.select,.textarea{width:100%;height:36px;margin:0;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans)} +.input.mono,.textarea.mono{font-family:var(--mono)} +.input.mono{font-size:14px} +.select{appearance:none;font-weight:500;padding:0 34px 0 14px;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2318212f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;cursor:pointer} +.textarea{height:56px;padding:10px 12px;line-height:1.4;resize:vertical} +.input.bad,.select.bad,.textarea.bad{border-color:#c62828} +.info{padding:10px 12px;border-radius:8px;background:#eaf0ff;color:#1e449e;font:400 13px/1.4 var(--sans)} +.err-banner{padding:10px 12px;border-radius:8px;background:#fbe6e6;color:#a32020;font:400 13px/1.4 var(--sans)} +.check{display:flex;align-items:flex-start;gap:10px;font:500 14px/1.3 var(--sans)} +.check input{width:16px;height:16px;margin:2px 0 0;accent-color:var(--primary)} +.check small{display:block;font:400 12px/1.3 var(--sans);color:var(--muted)} +.mini-table{border:1px solid var(--line);border-radius:8px;overflow:hidden;margin:16px 24px 8px} +.mini-table .tr{height:48px;padding:0 12px} +.mini-table .tr.th{height:36px;border-top:0} +.mini-table .tr:last-child{border-bottom:0} +.add-row{display:flex;align-items:center;gap:8px;padding:0 24px 20px} +.add-row .input{flex:1} + +/* misc */ +#toast-root{position:fixed;bottom:16px;right:16px;pointer-events:none;z-index:100;display:flex;flex-direction:column;gap:8px} +.toast{background:#18212f;color:#fff;padding:10px 14px;border-radius:8px;font:400 14px/1.3 var(--sans);box-shadow:0 8px 24px rgba(20,33,60,.25)} +.toast.err{background:#a32020} +.login{max-width:380px;margin:120px auto 0;padding:32px} +.login h1{font:600 22px/1.2 var(--sans);margin:20px 0 4px} +.login .field{margin-top:16px} +.login .btn{width:100%;margin-top:20px} + +.input:focus,.select:focus,.textarea:focus,.search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)} +.btn:focus-visible,.icon-btn:focus-visible,.tab:focus-visible{outline:2px solid var(--primary);outline-offset:2px} + +/* выпадающие списки в диалогах */ +.cselect{position:relative} +button.select{display:block;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +button.select.ph{color:var(--faint)} +.pop.dd{top:40px;left:0;right:0;min-width:0;max-height:240px} +.pop.dd[hidden]{display:none} +.pop button.sel{background:var(--hover)} + +/* журнал */ +.datebox{position:relative;display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 12px;width:170px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font:400 14px/1 var(--sans);flex:none} +.datebox input{flex:1;min-width:0;border:0;padding:0;background:transparent;color:var(--text);font:400 13px/1 var(--mono);outline:none} +.datebox input::-webkit-calendar-picker-indicator{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer} +.datebox .cal{display:flex;color:var(--muted);pointer-events:none} +.datebox:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)} +.kv{display:grid;grid-template-columns:130px 1fr;gap:12px;align-items:center;min-height:32px;font:400 14px/1.4 var(--sans)} +.json{margin:0;padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.2 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere} +.statbox{padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.5 var(--sans);color:var(--muted)} +.statbox b{color:var(--text);font-weight:600} +.warn{padding:10px 12px;border-radius:8px;background:#fdf0d5;color:#7c4a00;font:400 13px/1.4 var(--sans)} +.btn.danger:disabled{background:#f4f6f9;color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1} +.tr.clickable:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}