commit a846d3087294c61d11c4a71d5f0370ea2e7ac780 Author: ayurishchev Date: Sun Sep 20 12:27:47 2026 +0300 IPAM Manager: API, UI-админка, журнал аудита 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 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 0000000..20e72b0 Binary files /dev/null and b/web/fonts/ibm-plex-mono-cyrillic-400.woff2 differ diff --git a/web/fonts/ibm-plex-mono-cyrillic-500.woff2 b/web/fonts/ibm-plex-mono-cyrillic-500.woff2 new file mode 100644 index 0000000..328efe3 Binary files /dev/null and b/web/fonts/ibm-plex-mono-cyrillic-500.woff2 differ diff --git a/web/fonts/ibm-plex-mono-latin-400.woff2 b/web/fonts/ibm-plex-mono-latin-400.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/web/fonts/ibm-plex-mono-latin-400.woff2 differ diff --git a/web/fonts/ibm-plex-mono-latin-500.woff2 b/web/fonts/ibm-plex-mono-latin-500.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/web/fonts/ibm-plex-mono-latin-500.woff2 differ diff --git a/web/fonts/ibm-plex-sans-cyrillic-400.woff2 b/web/fonts/ibm-plex-sans-cyrillic-400.woff2 new file mode 100644 index 0000000..5ad40b4 Binary files /dev/null and b/web/fonts/ibm-plex-sans-cyrillic-400.woff2 differ 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 0000000..189f9dc Binary files /dev/null and b/web/fonts/ibm-plex-sans-cyrillic-500.woff2 differ 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 0000000..2c315c4 Binary files /dev/null and b/web/fonts/ibm-plex-sans-cyrillic-600.woff2 differ 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 0000000..f0ee65d Binary files /dev/null and b/web/fonts/ibm-plex-sans-latin-400.woff2 differ 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 0000000..6d5527e Binary files /dev/null and b/web/fonts/ibm-plex-sans-latin-500.woff2 differ diff --git a/web/fonts/ibm-plex-sans-latin-600.woff2 b/web/fonts/ibm-plex-sans-latin-600.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/web/fonts/ibm-plex-sans-latin-600.woff2 differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..01151b3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + +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}