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 <noreply@anthropic.com>
This commit is contained in:
commit
a846d30872
64 files changed
+4194
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Твоя роль
|
||||
|
||||
- DevOps инженер
|
||||
- Разработчик Backend
|
||||
- Архитектор информационных систем
|
||||
- Архитектор корпоративной сети
|
||||
|
||||
# Стиль общения
|
||||
|
||||
- профессиональный, но без жаргона
|
||||
|
||||
# Стиль ответов
|
||||
|
||||
- максимально емкие и содержательные
|
||||
- не проваливайся в лишние детали, если это явно не было запрошено
|
||||
|
||||
# Создание артефактов
|
||||
|
||||
- На каждое новое изменение должен быть артефакт в .md файле
|
||||
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
|
||||
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
|
||||
- каждое изменение дополняет или обновляет README.md
|
||||
|
||||
# Автотесты
|
||||
|
||||
- минимальное количество тестов
|
||||
|
||||
# Окружение для разработки
|
||||
|
||||
- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения)
|
||||
@@ -0,0 +1,8 @@
|
||||
venv/
|
||||
.env
|
||||
.git
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tests/
|
||||
.pytest_cache/
|
||||
docs/
|
||||
@@ -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=
|
||||
@@ -0,0 +1,5 @@
|
||||
venv/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
+10
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
+17
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -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
|
||||
@@ -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}
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
+81
@@ -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")
|
||||
+169
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+301
@@ -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
|
||||
@@ -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
|
||||
+107
@@ -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
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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-ов), пагинация «Показать ещё» для организаций/устройств, импорт/экспорт, экран журнала с фильтрами.
|
||||
@@ -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).
|
||||
@@ -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 загружались только на экране «Префиксы») — теперь запрашиваются и на экране адресов.
|
||||
- Нативный `<select>` во всех диалогах (VRF, статус, родитель, организация, тип, устройство) заменён на собственный выпадающий список в стиле меню строк:
|
||||
скруглённая карточка с тенью, подсветка выбранного и наведения, значение хранится в скрытом поле; закрытие по клику вне списка и по Esc
|
||||
(диалог при этом не закрывается), навигация стрелками ↑/↓. Это также ближе к макетам, где поля выбора — кнопки с шевроном.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Журнал: поиск по событиям, ротация, очистка и UI (изменение 003)
|
||||
|
||||
## Context
|
||||
Сейчас `audit_log` — сырая таблица (`ts, username, entity_type, entity_id, entity_label, action, diff`), экран «Журнал» — простая таблица
|
||||
без фильтров и без управления размером; журнал растёт бесконечно. В макетах канваса (страница «ROS Manager», листы Journal / JournalEntry /
|
||||
JournalSettings / JournalClear) уже нарисован целевой журнал: фильтры и поиск, окно записи, настройки ротации, очистка с паролем.
|
||||
Цель — реализовать это для IPAM (backend + UI), адаптировав поля под IPAM. Решения из блока «Журнал: вопросы для ревью» канваса берём как
|
||||
принятые: короткий ID 8 символов; «Показать ещё 100»; ротация по умолчанию 90 дней и 100 000 записей (0 — без ограничения);
|
||||
очистка по паролю пользователя, 5 неверных попыток за 10 минут → блокировка на 10 минут; настройки ротации сохраняются без пароля;
|
||||
строка таблицы целиком кликабельна и открывает окно записи; «Очистить журнал» — белая кнопка с красным текстом.
|
||||
|
||||
## Модель данных (миграция 0003)
|
||||
- `audit_log`: `+uid UUID` (default `gen_random_uuid()`, уникальный; в UI `evt_<uuid>`, короткий вид — первые 8 символов),
|
||||
`+message TEXT` (человекочитаемое: «Префикс 10.30.0.0/24 создан», для изменений — с перечнем полей); бэкфилл существующих строк.
|
||||
Тип события = `<entity_type>.<action>` (вычисляется, отдельной колонки нет). Актор = `username` для `system`/`anonymous`, иначе `ui:<username>`.
|
||||
- `app_settings(key PK, value JSONB)` — ключ `journal` = `{retention_days: 90, max_entries: 100000}` (создаётся с дефолтами).
|
||||
- `clear_attempts(id, user_id FK, ts)` — неудачные попытки подтверждения пароля при очистке (счётчик и блокировка в БД, переживают рестарт).
|
||||
- Индексы: `(entity_type, action)`; поиск по сообщению/ID — `ILIKE` (при лимите 100 000 записей достаточно; `pg_trgm` — при необходимости позже).
|
||||
|
||||
## Backend
|
||||
Новый `app/api/v1/journal.py` (перенос `/audit` из `overview.py`), `app/rotation.py`.
|
||||
- `GET /audit` — фильтры `event_type`, `entity_type`, `actor`, `date_from`, `date_to` (UTC, включительно), `q` (message / entity_label / начало uid),
|
||||
`limit`, `offset`; сортировка `id DESC`. Ответ: `items[{uid, short_id, ts, event_type, entity_type, entity_id, entity_label, actor, message, diff}]`, `total`.
|
||||
- `GET /audit/summary` — `total`, `oldest_ts`, `retention_days`, `max_entries` (для строки «1 284 записи · старейшая … · ротация …»).
|
||||
- `GET /audit/facets` — списки значений для фильтров: типы событий, акторы, сущности.
|
||||
- `GET /audit/{uid}` — запись целиком (окно записи).
|
||||
- `GET|PUT /journal/settings` — чтение всем, запись только admin; валидация: целые ≥ 0 (дни ≤ 3650, записей ≤ 10 000 000); после сохранения — немедленная ротация.
|
||||
- `POST /journal/clear {password}` — только admin: проверка пароля текущего пользователя (argon2); неверный → 403 `{attempts_left}`;
|
||||
5 неверных за 10 минут → 429 `{retry_after_seconds}`; успех: удалить все записи и в той же транзакции записать `journal.cleared` (кто, сколько удалено).
|
||||
Обработчик ошибок расширяется: `detail` может быть dict (доп. поля в тело ответа).
|
||||
- Ротация (`rotate(db)`): удаляет записи старше `retention_days` и самые старые сверх `max_entries`; при удалении >0 пишет `journal.rotated`
|
||||
(`system`, кол-во и причина). Запуск: фоновая задача раз в час (asyncio в `lifespan`, работа в потоке) и при сохранении настроек;
|
||||
защита от параллельного запуска в нескольких репликах — `pg_try_advisory_lock`.
|
||||
- Новые события в журнал: `auth.login`, `auth.failed` (`anonymous`), `journal.settings_updated`, `journal.cleared`, `journal.rotated`;
|
||||
`services.audit()` получает генерацию `message` и `uid`.
|
||||
- Обратная совместимость: поля `/audit`, используемые Обзором (`entity_label`, `action`, `username`, `ts`), сохраняются.
|
||||
|
||||
## UI (`web/app.js`, `web/styles.css`) — по макетам Journal*
|
||||
- Экран «Журнал»: заголовок + строка «N записей · старейшая <дата> · ротация: 90 дней / 100 000 записей»; кнопки «Обновить», «Настройки»,
|
||||
«Очистить журнал» (admin; для viewer скрыты/неактивны). Фильтры: «Тип», «Актор», «Сущность» (выпадающие списки в стиле меню — уже есть `filterBtn`),
|
||||
период «с … по …» (`input type=date`), поиск «Сообщение или ID» (debounce, как в остальных экранах). Таблица: Время (UTC, с секундами), Тип (бейдж),
|
||||
Сущность (тип · метка), Сообщение, Актор, ID записи (8 симв., моно); «Показать ещё 100», «Показано X из N записей».
|
||||
- Окно записи (640 px): ID с кнопкой «Копировать» (fallback через `execCommand`, т.к. по http `navigator.clipboard` недоступен), время, тип, актор, сущность, сообщение, блок «Данные» (JSON `diff`).
|
||||
- Окно «Настройки журнала» (560 px): текущее число записей и старейшая, поля «Хранить записи, дней» и «Максимум записей», пояснение о ротации.
|
||||
- Окно «Очистить журнал» (540 px): предупреждение, поле пароля, кнопка неактивна до ввода; состояния «Неверный пароль. Осталось попыток: N» и блокировка «Повторите через N мин.» (поле отключено).
|
||||
- Бейджи типов: created — синий, assigned — зелёный, updated — янтарный, deleted/auth.failed — красный, остальные — серый.
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать этот план в `docs/changes/003-journal-search-rotation/PLAN.md` (требование проекта).
|
||||
1. Миграция 0003 + модели; бэкфилл `message`/`uid`.
|
||||
2. `services.audit()` (message, uid) и новые события (`auth.*`).
|
||||
3. `journal.py`: список/поиск/summary/facets/запись; перенос `/audit`.
|
||||
4. Настройки, ротация (`rotation.py`, фоновая задача, advisory lock), очистка с блокировкой.
|
||||
5. UI: экран, окна записи/настроек/очистки.
|
||||
6. Тесты, прогон сценария UI, сверка окон с макетами, README, SUMMARY.
|
||||
|
||||
## Тесты (+3, всего 9; против контейнеров, свои учётные данные из `.env`)
|
||||
1. Поиск и фильтры: создать префикс → находится по `q` (метка, начало uid), по `event_type`, `actor`; `date_from` в будущем → пусто; `/audit/{uid}`.
|
||||
2. Ротация: запись с `ts` старше срока (вставка через БД по DSN из `.env`) и превышение `max_entries` удаляются при `PUT /journal/settings`;
|
||||
появляется `journal.rotated`; настройки в конце восстанавливаются к 90/100 000.
|
||||
3. Очистка: отдельный временный пользователь (вставка в БД, удаляется после теста, чтобы не блокировать `admin`): неверный пароль → 403 с `attempts_left`,
|
||||
5 неверных → 429; успешную очистку в общей базе не гоняем — она проверяется вручную в сценарии UI на сбрасываемой базе.
|
||||
|
||||
## Проверка end-to-end
|
||||
`docker compose up -d --build` (миграции автоматически) → `pytest` → сценарий в браузере: создать/изменить объекты → найти событие по тексту,
|
||||
типу, актору и периоду → открыть запись и скопировать ID → сменить ротацию на малый лимит и увидеть усечение и запись `journal.rotated` →
|
||||
очистка: неверный пароль (счётчик попыток), блокировка, успешная очистка → в журнале одна запись `journal.cleared`; после этого база пересоздаётся и заполняется `seed_demo.py`.
|
||||
Сверка окон записи/настроек/очистки с макетами Journal* (отличия шапки и названий — из-за другого приложения, ROS Manager).
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- Запись `journal.rotated` создаётся только когда что-то удалено (иначе каждый час по записи).
|
||||
- Смена регистра/формата: поиск `q` без учёта регистра, подстрока в сообщении и метке, префикс для ID.
|
||||
- Записи `auth.failed` не ограничиваются отдельно; рост от подбора паролей сдерживает ротация.
|
||||
- Экспорт журнала (CSV) и права viewer на очистку/настройки не входят.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Суммаризация: журнал — поиск, ротация, очистка, UI (изменение 003)
|
||||
|
||||
## Сделано
|
||||
- **БД (миграция 0003):** `audit_log` + `uid` (UUID) и `message` (бэкфилл существующих записей), индекс `(entity_type, action)`;
|
||||
таблицы `app_settings` (настройки ротации, по умолчанию 90 дней / 100 000 записей) и `clear_attempts` (неудачные попытки очистки).
|
||||
Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`.
|
||||
- **API:** `GET /audit` с поиском и фильтрами (текст/ID, тип события, сущность, актор, период UTC, «Показать ещё»), `/audit/summary`, `/audit/facets`,
|
||||
`/audit/{uid}`; `GET|PUT /journal/settings`; `POST /journal/clear` (пароль, 5 попыток / блокировка 10 минут).
|
||||
- **Ротация:** `app/rotation.py` — по сроку и по количеству; фоновая задача раз в час и сразу при сохранении настроек; PG advisory lock;
|
||||
сводная запись `journal.rotated` (итог после ротации не превышает лимит). Экранирование спецсимволов LIKE в поиске.
|
||||
- **События:** добавлены `session.login` / `session.failed`, `journal.settings_updated` / `rotated` / `cleared`; у каждой записи человекочитаемое сообщение.
|
||||
- **UI:** экран «Журнал» по макету Journal (строка «N записей · старейшая … · ротация …», фильтры Тип/Актор/Сущность/период/поиск, таблица, «Показать ещё 100»),
|
||||
окно записи (ID с копированием, данные JSON), окно «Настройки журнала», окно «Очистить журнал» (кнопка неактивна без пароля, счётчик попыток, блокировка).
|
||||
Настройки и очистка видны только admin.
|
||||
|
||||
## Проверка
|
||||
- 9 автотестов (3 новых: поиск и фильтры; ротация по сроку и по количеству; очистка — неверный пароль и блокировка на временном пользователе) — **9 passed** (дважды подряд).
|
||||
- Сценарий UI в браузере: фильтры, поиск, период, окно записи, копирование, настройки, ротация до 50 записей, неверный пароль → счётчик → блокировка,
|
||||
успешная очистка (остаётся одна запись `journal.cleared`), ошибок JS нет. Успешная очистка проверена на пересозданной БД; после проверки БД снова заполнена демо-данными.
|
||||
- Сверка с макетами Journal / JournalEntry / JournalSettings / JournalClear (они нарисованы для ROS Manager): сетка, размеры, цвета, состояния совпадают;
|
||||
отличия — предметные: колонка «Сущность» вместо «Устройство», типы событий IPAM.
|
||||
|
||||
## Допущения и ограничения
|
||||
- Запись `journal.rotated` создаётся только если что-то удалено.
|
||||
- Формат поля даты (`input type=date`) зависит от локали браузера (в макете — ISO).
|
||||
- Записи `auth.failed` отдельно не ограничиваются; рост от подбора паролей сдерживает ротация.
|
||||
- Экспорт журнала и управление пользователями не входят.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Журнал аудита: IP-адрес актора и метаданные запроса (изменение 004)
|
||||
|
||||
## Context
|
||||
В журнале видно, *кто* и *что* сделал (`actor`, `message`), но не *откуда*. Нужно фиксировать IP-адрес, с которого пользователь (в т.ч. admin)
|
||||
выполнил изменение, и сопутствующие метаданные, и показывать их в UI. Сейчас `audit()` (`app/services.py`) не знает о запросе:
|
||||
вызывается из ~30 мест, IP нигде не сохраняется.
|
||||
|
||||
Что выяснено (read-only проверка):
|
||||
- Приложение публикуется через Docker (`0.0.0.0:8088 → 172.x:8000`). Запрос с LAN-адреса машины приходит с реальным источником (`192.168.5.9`),
|
||||
с удалённых хостов — тоже (DNAT сохраняет источник); запрос через `127.0.0.1` приходит как адрес шлюза docker-сети (`172.29.0.1`) —
|
||||
это особенность `docker-proxy` для loopback, не ошибка приложения. Отдельная прокси-настройка для получения реального IP не нужна.
|
||||
- Если позже перед приложением появится reverse-proxy с TLS (как предлагалось), реальный IP будет в `X-Forwarded-For`, но доверять заголовку можно
|
||||
только от известных прокси — иначе IP подделывается. Поэтому закладываем настройку доверенных прокси (по умолчанию пусто).
|
||||
|
||||
Заодно (жалоба «не вижу обновлений»): сервер отдаёт актуальные `app.js`/`styles.css` (проверено по LAN-адресу), но без `Cache-Control` —
|
||||
браузер может держать старую версию по эвристике. Включаем ревалидацию (`Cache-Control: no-cache` + уже есть ETag).
|
||||
|
||||
## Решение
|
||||
**Каждая запись журнала, созданная в рамках HTTP-запроса, получает `client_ip` и `meta`**; записи, созданные системой (ротация, миграции) — без IP.
|
||||
Это покрывает и действия admin, и неудачные входы (`anonymous`, самый ценный случай для расследования), и любые будущие роли.
|
||||
|
||||
## Модель данных (миграция 0004)
|
||||
- `audit_log`: `+client_ip INET NULL` (индекс), `+meta JSONB NULL` — расширяемые метаданные запроса:
|
||||
`{"user_agent": "…", "method": "POST", "path": "/api/v1/prefixes", "request_id": "…"}` (User-Agent ≤ 255 символов; только путь, без query-строки).
|
||||
- Существующие записи остаются с `NULL` (в UI «—»). Даунгрейд удаляет колонки.
|
||||
|
||||
## Backend
|
||||
- `app/request_context.py` (новый): `ContextVar` с метаданными текущего запроса и ASGI-middleware `RequestContextMiddleware`:
|
||||
определяет IP клиента, User-Agent, метод, путь, генерирует `request_id` (возвращается в заголовке `X-Request-ID`).
|
||||
`resolve_client_ip(peer, x_forwarded_for, trusted)` — чистая функция: `X-Forwarded-For` учитывается **только если сокет-пир входит в `TRUSTED_PROXIES`**
|
||||
(берётся первый недоверенный адрес справа); иначе — адрес пира. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) нормализуется в IPv4.
|
||||
- `app/config.py`: `trusted_proxies: str = ""` (CIDR через запятую); `.env.example`, `docker-compose.yml` (пробросить `TRUSTED_PROXIES`).
|
||||
- `app/services.py::audit()`: читает контекст запроса и заполняет `client_ip`/`meta`; для `user=SYSTEM` (ротация, в т.ч. запущенная из запроса на сохранение настроек) IP не пишется.
|
||||
Сигнатура `audit()` не меняется — 30 мест вызова не трогаем.
|
||||
- `app/main.py`: подключить middleware; для статики — `Cache-Control: no-cache` (подкласс `StaticFiles`).
|
||||
- `GET /audit`: новый фильтр `client_ip` (точный IP или подсеть CIDR — `inet <<=`); текстовый поиск `q` дополнительно ищет по началу IP.
|
||||
`AuditOut` (+`from_row`): поля `client_ip: str | None`, `meta: dict | None`. Остальные поля/эндпоинты без изменений (Обзор не затронут).
|
||||
|
||||
## UI (`web/app.js`, `web/styles.css`)
|
||||
- Таблица журнала: новая колонка «IP-адрес» (моно 13 px, «—» если нет) — сетка `150px 168px 150px 1fr 110px 130px 96px`; поиск: «Сообщение, ID или IP».
|
||||
- Окно записи: строки «IP-адрес» (с кнопкой «Копировать») , «User-Agent», «Запрос» (`POST /api/v1/prefixes`); блок «Данные» остаётся для `diff`.
|
||||
- Отступление от макета Journal (он без IP) — сознательное, по запросу пользователя; остальная вёрстка не меняется.
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать план в `docs/changes/004-audit-client-ip/PLAN.md`.
|
||||
1. Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade).
|
||||
2. `request_context.py`, конфиг, middleware, `audit()`.
|
||||
3. `/audit`: поле, фильтр, поиск; `Cache-Control` для статики.
|
||||
4. UI: колонка и строки окна записи.
|
||||
5. Тесты, проверка через LAN-адрес и в браузере, README, SUMMARY.
|
||||
|
||||
## Тесты (+2, всего 11; против контейнеров)
|
||||
1. Запись IP: действие через API с заголовком `User-Agent: ipam-tests/1.0` → в `/audit` у записи есть валидный `client_ip` и `meta.user_agent`/`method`/`path`;
|
||||
фильтр `client_ip` находит запись, чужой IP/подсеть — нет; поддельный `X-Forwarded-For: 203.0.113.9` от недоверенного пира **не** попадает в журнал;
|
||||
у записи ротации (`system`) `client_ip = null`.
|
||||
2. Юнит-тест `resolve_client_ip` (без контейнеров): недоверенный пир игнорирует XFF; доверенный — берёт первый недоверенный справа; IPv4-mapped нормализуется.
|
||||
|
||||
## Проверка end-to-end
|
||||
`docker compose up -d --build` → `pytest` → запрос к API через `http://192.168.5.9:8088` (реальный источник) и через `127.0.0.1` (адрес шлюза docker) →
|
||||
в UI «Журнал» видна колонка IP, окно записи показывает IP/User-Agent/запрос, поиск по IP находит запись → `curl -I` статики показывает `Cache-Control: no-cache`.
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- IP пишется для всех действий из HTTP-запросов (не только admin) — иначе журнал неоднородный; для system — пусто.
|
||||
- Запросы с самой машины через `127.0.0.1` показывают адрес шлюза docker-сети; это поведение Docker и документируется в README.
|
||||
- IP — персональные данные: срок хранения регулируется существующей ротацией журнала; отдельная обезличка не делается.
|
||||
- За NAT/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Суммаризация: IP-адрес актора и метаданные запроса в журнале (изменение 004)
|
||||
|
||||
## Сделано
|
||||
- **БД (миграция 0004):** `audit_log` + `client_ip INET` (индекс) и `meta JSONB` (`user_agent`, `method`, `path`, `request_id`). Проверено: `upgrade → check (без дрейфа) → downgrade → upgrade`.
|
||||
- **Backend:** `app/request_context.py` — ASGI-middleware кладёт метаданные запроса в `ContextVar`, `audit()` читает их (30 мест вызова не менялись);
|
||||
заголовок `X-Request-ID` в ответах. `X-Forwarded-For` принимается только от прокси из `TRUSTED_PROXIES` (по умолчанию пусто, справа налево — первый недоверенный адрес), IPv4-mapped нормализуется.
|
||||
Системные события (ротация, даже запущенная из запроса) пишутся без IP.
|
||||
- **API:** `AuditOut.client_ip` / `meta`; `GET /audit?client_ip=` (IP или CIDR, 422 на мусор); `q` ищет и по началу IP.
|
||||
- **UI:** колонка «IP-адрес» в таблице журнала (сетка `… 110px 130px 96px`), поиск «Сообщение, ID или IP», в окне записи — IP (с копированием), «Запрос», «User-Agent».
|
||||
- **Кэш статики:** `Cache-Control: no-cache` (ревалидация по ETag) — браузер больше не держит устаревший `app.js`/`styles.css` после обновления
|
||||
(ответ на «не вижу обновлений»; для уже закэшированной версии один раз нужен Ctrl+F5).
|
||||
|
||||
## Проверка
|
||||
- 11 автотестов (2 новых: запись IP/метаданных, фильтр по IP и подсети, игнорирование поддельного `X-Forwarded-For`, пустой IP у системных событий; юнит-тест разбора цепочки прокси) — **11 passed**.
|
||||
- Через LAN-адрес `192.168.5.9:8088`: создание организации и неудачный вход записались с `client_ip = 192.168.5.9`, User-Agent и путём; фильтр `192.168.5.0/24` и поиск `192.168.5` находят записи; UI — колонка и окно записи проверены в браузере, ошибок JS нет.
|
||||
|
||||
## Допущения и ограничения
|
||||
- IP пишется для всех действий из HTTP-запросов (admin и прочие роли, неудачные входы — как `anonymous`); старые записи — «—».
|
||||
- Через `127.0.0.1` Docker подставляет адрес шлюза сети (`172.x.0.1`) — особенность docker-proxy, не ошибка приложения.
|
||||
- За NAT или прокси виден адрес ближайшего недоверенного узла, а не «настоящего» клиента; с reverse-proxy нужно задать `TRUSTED_PROXIES`.
|
||||
- IP — персональные данные: срок хранения определяется ротацией журнала.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Кликабельные строки во всех реестрах UI (изменение 005)
|
||||
|
||||
## Context
|
||||
В журнале аудита строка таблицы целиком кликабельна (`.tr.clickable`, `data-action="jr-open"`, курсор-указатель, подсветка при наведении).
|
||||
В остальных реестрах кликабельно только отдельное значение: на «Префиксах» — сам CIDR (ссылка) у листовых строк и шеврон у родительских.
|
||||
Это неудобно: попадать нужно в узкую цель. Задача — сделать строки кликабельными по единому паттерну журнала во всех реестрах
|
||||
(решения пользователя: родитель → свернуть/развернуть ветку, дочерний (лист) → открыть адреса; распространить на все реестры).
|
||||
|
||||
## Поведение по экранам (`web/app.js`)
|
||||
| Экран | Клик по строке | Уже работающие элементы (сохраняются) |
|
||||
|---|---|---|
|
||||
| Префиксы — лист (нет вложенных) | открыть адреса подсети (`#/prefixes/<id>`) | ссылка-CIDR, меню «⋯» |
|
||||
| Префиксы — родитель (есть вложенные) | свернуть/развернуть ветку | шеврон |
|
||||
| Организации | открыть префиксы организации (выбрать её и перейти на «Префиксы») | ссылки «название» и «число префиксов», меню «⋯» |
|
||||
| Операторы | окно редактирования оператора | ссылка на организацию, меню «⋯» |
|
||||
| Устройства | окно редактирования устройства | ссылка «число IP», меню «⋯» |
|
||||
| Адреса подсети — занятый/резерв/устаревший | окно редактирования адреса | меню «⋯» |
|
||||
| Адреса подсети — «Свободен» | окно «Назначить адрес» с подставленным IP | меню «⋯» |
|
||||
| Журнал | без изменений (уже так) | — |
|
||||
Не входят: таблицы на «Обзоре» (это сводка, не реестры) — при желании отдельной доработкой.
|
||||
|
||||
## Реализация
|
||||
- Строка получает `class="tr hoverable clickable"` + `data-action="<действие>"` + `data-id`/`data-ip` (как `jr-open`). Новые действия переиспользуют существующие
|
||||
обработчики: `pfx-open` (навигация), `toggle` (уже есть), `org-open` (`store.orgId = id; location.hash = "#/prefixes"`), `isp-edit` → `ispDialog`, `dev-edit` → `deviceDialog`,
|
||||
`adr-edit` → `addressDialog(a)`, `adr-assign` → `addressDialog(null, ip)`; данные строки берутся из `S.rows`/`S.page.items` через существующий `rowById`.
|
||||
- **Защита от лишних срабатываний** в едином глобальном обработчике клика (`document.addEventListener("click")`): действие строки не выполняется, если клик
|
||||
пришёл из `a[href]`, `button`, `input`/`label`/`select`/`textarea`, из всплывающего меню `.pop`, при нажатых Ctrl/Cmd/Shift (открытие ссылки в новой вкладке),
|
||||
при выделенном тексте (`getSelection()`), а также если в этот момент было открыто меню (тогда клик лишь закрывает меню). Вложенные элементы с собственным `data-action`
|
||||
(шеврон, «⋯», пункты меню) по-прежнему выигрывают у строки — берётся ближайший `[data-action]`.
|
||||
- Клавиатура: строки с вложенными элементами управления не делаем `role="button"` (вложенные интерактивные элементы — a11y-антипаттерн); доступ с клавиатуры остаётся
|
||||
через ссылку и меню «⋯» в строке. Шеврону добавляем `aria-expanded`.
|
||||
- Стили: `.tr.clickable{cursor:pointer}` и подсветка `.tr.hoverable:hover` уже есть; вёрстка строк, размеры и цвета не меняются (макеты не затрагиваются).
|
||||
|
||||
## Порядок работ
|
||||
0. Скопировать план в `docs/changes/005-clickable-rows/PLAN.md`.
|
||||
1. Строки и действия на 5 экранах + защита в глобальном обработчике.
|
||||
2. Проверка сценарием в браузере (Playwright, как в прошлых доработках) и регрессия вёрстки (сравнение скриншотов со сверкой макетов — без изменений).
|
||||
3. README (строка про поведение), `SUMMARY.md`.
|
||||
|
||||
## Проверка
|
||||
Сценарий в браузере на `http://192.168.5.9:8088`: клик по пустому месту строки — лист «Префиксов» открывает адреса, родитель сворачивает/разворачивает; Организации →
|
||||
префиксы нужной организации; Операторы/Устройства/Адреса → окно редактирования; свободный адрес → «Назначить адрес» с этим IP; клик по «⋯», шеврону, пункту меню, ссылке
|
||||
не вызывает действие строки дважды; Ctrl+клик по ссылке открывает новую вкладку без действия строки; выделение текста мышью не открывает диалог;
|
||||
клик при открытом меню только закрывает его. Автотесты бэкенда (11) остаются зелёными — backend не меняется; новых pytest-тестов не добавляем
|
||||
(правило проекта — минимум тестов, изменение чисто интерфейсное).
|
||||
|
||||
## Допущения (скажите, если не так)
|
||||
- У родительской строки нельзя открыть адреса, назначенные прямо на неё (как и сейчас) — только раскрыть ветку.
|
||||
- Клик по строке организации ведёт в её префиксы (это уже основной сценарий: название и счётчик были ссылками туда), а не в окно редактирования; редактирование — через «⋯».
|
||||
@@ -0,0 +1,23 @@
|
||||
# Суммаризация: кликабельные строки во всех реестрах (изменение 005)
|
||||
|
||||
## Сделано
|
||||
Изменён только UI (`web/app.js`); backend и БД не менялись.
|
||||
- Строки получают `clickable` + `data-row`/`data-action`, как в журнале: «Префиксы» — лист → открыть адреса, родитель → свернуть/развернуть ветку;
|
||||
«Организации» → префиксы организации; «Операторы» / «Устройства» → окно редактирования; «Адреса» — занятый → редактирование, свободный → «Назначить адрес» с подставленным IP.
|
||||
Шеврону добавлен `aria-expanded`.
|
||||
- В едином глобальном обработчике клика действие строки не выполняется, если клик по ссылке, кнопке, полю формы или всплывающему меню, при Ctrl/Cmd/Shift/Alt,
|
||||
при выделенном тексте; если открыто меню — клик по строке только закрывает его. Вложенные `data-action` (шеврон, «⋯», пункты меню) по-прежнему приоритетнее строки.
|
||||
- Вёрстка, размеры и цвета не менялись (стили `.tr.clickable`/`.hoverable` уже существовали).
|
||||
|
||||
## Проверка
|
||||
- Сценарий в браузере по `http://192.168.5.9:8088` (15 проверок, все пройдены, ошибок JS нет): клик по строке каждого реестра; родитель сворачивается/разворачивается;
|
||||
шеврон срабатывает один раз; клик по «⋯» и пунктам меню не запускает действие строки; при открытом меню клик по строке лишь закрывает его; Shift-клик и выделение текста игнорируются;
|
||||
Ctrl+клик по ссылке открывает новую вкладку, текущая не навигирует; ссылка организации в «Операторах» работает без открытия диалога.
|
||||
- 11 автотестов backend — зелёные. Новых pytest-тестов не добавлено (изменение интерфейсное; правило проекта — минимум тестов).
|
||||
- Повторная сверка со скриншотами макетов не выполнялась: в БД, изменённой при ручном тестировании (удалены часть префиксов демо-данных), нужные для неё объекты отсутствуют;
|
||||
разметка строк изменилась только атрибутами.
|
||||
|
||||
## Ограничения
|
||||
- Клавиатура: строки не делаем `role="button"` (внутри есть ссылки и кнопки); доступ остаётся через ссылку и меню «⋯».
|
||||
- У родительского префикса нельзя открыть адреса, назначенные прямо на него, — только раскрыть ветку.
|
||||
- Таблицы «Обзора» (сводка) не менялись.
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8
|
||||
httpx>=0.27
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
sqlalchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2
|
||||
alembic>=1.13
|
||||
pydantic-settings>=2.4
|
||||
email-validator>=2.2
|
||||
pyjwt>=2.9
|
||||
argon2-cffi>=23.1
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Генерирует .env со случайными учётными данными (не перезаписывает существующий)."""
|
||||
import pathlib
|
||||
import secrets
|
||||
|
||||
env = pathlib.Path(__file__).resolve().parent.parent / ".env"
|
||||
if env.exists():
|
||||
print(f"{env} уже существует — пропускаю")
|
||||
else:
|
||||
env.write_text(
|
||||
"POSTGRES_DB=ipam\n"
|
||||
"POSTGRES_USER=ipam\n"
|
||||
f"POSTGRES_PASSWORD={secrets.token_urlsafe(24)}\n"
|
||||
f"JWT_SECRET={secrets.token_urlsafe(48)}\n"
|
||||
"ADMIN_USERNAME=admin\n"
|
||||
f"ADMIN_PASSWORD={secrets.token_urlsafe(18)}\n"
|
||||
"APP_PORT=8088\n"
|
||||
"DB_HOST_PORT=55432\n"
|
||||
)
|
||||
env.chmod(0o600)
|
||||
print(f"создан {env}")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Заполняет БД вымышленными данными из макетов IPAM Manager (через API). Запуск: venv/bin/python scripts/seed_demo.py"""
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
env = dict(l.split("=", 1) for l in pathlib.Path(__file__).resolve().parent.parent.joinpath(".env").read_text().split() if "=" in l)
|
||||
base = os.environ.get("BASE_URL", f"http://127.0.0.1:{env['APP_PORT']}") + "/api/v1"
|
||||
c = httpx.Client(base_url=base, timeout=30)
|
||||
tok = c.post("/auth/login", json={"username": env["ADMIN_USERNAME"], "password": env["ADMIN_PASSWORD"]}).json()["access_token"]
|
||||
c.headers["Authorization"] = f"Bearer {tok}"
|
||||
|
||||
|
||||
def post(path, **body):
|
||||
r = c.post(path, json=body)
|
||||
if r.status_code >= 400:
|
||||
sys.exit(f"{path}: {r.status_code} {r.text}")
|
||||
return r.json()
|
||||
|
||||
|
||||
orgs_data = [
|
||||
("ООО «Технологии связи»", "ТехСвязь", "7701234567", "Москва, ул. Ленина, 15"),
|
||||
("АО «Центр-Телеком»", "ЦентрТК", "7709876543", "СПб, пр. Невский, 100"),
|
||||
("ИП Петров А.В.", "Петров", "770100001234", "Казань, ул. Баумана, 5"),
|
||||
("ООО «СтройМонтаж»", "СтройМ", "7705551234", "Москва, Кутузовский, 32"),
|
||||
("ООО «Дата-Центр»", "ДЦ", "7704443322", "Москва, Бутово, пром. зона"),
|
||||
]
|
||||
orgs = [post("/organizations", name=n, short_name=s, inn=i, address=a) for n, s, i, a in orgs_data]
|
||||
org = orgs[0]
|
||||
vrf = {v["name"]: v["id"] for v in c.get("/vrfs", params={"organization_id": org["id"]}).json()["items"]}
|
||||
vrf["management"] = post("/vrfs", organization_id=org["id"], name="management", route_target="65000:2")["id"]
|
||||
vrf["guest"] = post("/vrfs", organization_id=org["id"], name="guest", route_target="65000:3")["id"]
|
||||
|
||||
prefixes = {}
|
||||
for cidr, desc, v, status, pool in [
|
||||
("10.0.0.0/8", "Внутренняя сеть", "default", "active", False),
|
||||
("10.10.0.0/16", "Москва — серверы и офис", "default", "active", False),
|
||||
("10.10.1.0/24", "Серверная — production", "default", "active", True),
|
||||
("10.10.2.0/24", "Серверная — staging", "default", "active", True),
|
||||
("10.20.0.0/16", "Санкт-Петербург", "default", "active", False),
|
||||
("10.20.0.0/24", "СПб — офис", "default", "active", False),
|
||||
("192.168.0.0/16", "Офисные сети", "default", "active", False),
|
||||
("192.168.10.0/24", "Офис — пользователи", "default", "active", False),
|
||||
("192.168.20.0/24", "Офис — принтеры", "default", "active", False),
|
||||
("172.16.0.0/12", "Резерв — будущее расширение", "default", "reserved", False),
|
||||
("fd00::/8", "ULA — внутренняя IPv6", "default", "active", False),
|
||||
("10.99.0.0/24", "Управление оборудованием", "management", "active", False),
|
||||
("10.98.0.0/24", "Гостевой Wi-Fi", "guest", "active", False),
|
||||
]:
|
||||
prefixes[cidr] = post("/prefixes", organization_id=org["id"], vrf_id=vrf[v], prefix=cidr, description=desc, status=status, is_pool=pool)["id"]
|
||||
|
||||
types = {t["name"]: t["id"] for t in c.get("/device-types").json()["items"]}
|
||||
devs = {}
|
||||
for name, t, note in [
|
||||
("gw-prod.internal", "Сетевое оборудование", "Шлюз — MSK-Core-01"),
|
||||
("db-master.internal", "Сервер", "PostgreSQL primary"),
|
||||
("db-replica.internal", "Сервер", "PostgreSQL replica"),
|
||||
("app-01.internal", "Сервер", "Backend API node 1"),
|
||||
("monitoring.internal", "Сервер", "Prometheus + Grafana"),
|
||||
("old-nas.internal", "Сетевое хранилище", "NAS — выведен из эксплуатации"),
|
||||
]:
|
||||
devs[name] = post("/devices", name=name, device_type_id=types[t], organization_id=org["id"], note=note)["id"]
|
||||
|
||||
|
||||
def addr(cidr, ip, **kw):
|
||||
post(f"/prefixes/{prefixes[cidr]}/addresses", address=ip, **kw)
|
||||
|
||||
|
||||
P = "10.10.1.0/24"
|
||||
named = {1: ("gw-prod.internal", "Шлюз — MSK-Core-01"), 2: ("db-master.internal", "PostgreSQL primary"), 3: ("db-replica.internal", "PostgreSQL replica"), 5: ("app-01.internal", "Backend API node 1"), 105: ("monitoring.internal", "Prometheus + Grafana")}
|
||||
reserved = {4: "VIP для балансировки", 6: "Резерв", 7: "Резерв", 8: "Резерв"}
|
||||
free = {100, 101, 102, 103, 104, 106, 107, 108} # 8 свободных
|
||||
n_assigned = 0
|
||||
for i in range(1, 255):
|
||||
ip = f"10.10.1.{i}"
|
||||
if i in free:
|
||||
continue
|
||||
if i == 200:
|
||||
addr(P, ip, status="deprecated", dns_name="old-nas.internal", description="NAS — выведен", device_id=devs["old-nas.internal"])
|
||||
elif i in reserved:
|
||||
addr(P, ip, status="reserved", description=reserved[i])
|
||||
elif i in named:
|
||||
dns, d = named[i]
|
||||
addr(P, ip, dns_name=dns, description=d, device_id=devs[dns])
|
||||
else:
|
||||
addr(P, ip, dns_name=f"host-{i}.internal", description="Сервис")
|
||||
# второй IP у monitoring — в staging-подсети
|
||||
addr("10.10.2.0/24", "10.10.2.10", dns_name="monitoring.internal", description="Prometheus + Grafana", device_id=devs["monitoring.internal"])
|
||||
for cidr, prefix, count in [("10.10.2.0/24", "10.10.2", 217), ("192.168.10.0/24", "192.168.10", 196), ("10.20.0.0/24", "10.20.0", 162), ("192.168.20.0/24", "192.168.20", 30)]:
|
||||
for i in range(20 if cidr == "10.10.2.0/24" else 1, count + (20 if cidr == "10.10.2.0/24" else 1)):
|
||||
addr(cidr, f"{prefix}.{i}", dns_name=f"h{i}.{prefix.replace('.', '-')}.internal")
|
||||
for i in range(1, 49):
|
||||
addr("fd00::/8", f"fd00::{i:x}", description="IPv6 узел")
|
||||
|
||||
for name, org_i, nets, hot, contract, note in [
|
||||
("Ростелеком", 0, ["85.249.12.0/24", "85.249.13.0/24"], "8-800-100-0800", "РТ-2024/156", "Основной канал 1G"),
|
||||
("МТС", 1, ["213.87.0.0/16"], "8-800-250-0890", "МТС-2025/042", "Резервный канал"),
|
||||
("Мегафон", 0, ["178.176.72.0/21", "178.176.80.0/21", "178.176.88.0/21"], "8-800-550-0500", "МФ-2024/089", "VPN филиалов"),
|
||||
("ТТК", 4, ["195.66.128.0/19"], "8-800-775-0775", "ТТК-2025/011", "ДЦ Бутово"),
|
||||
]:
|
||||
post("/isps", name=name, organization_id=orgs[org_i]["id"], networks=nets, hotline=hot, contract_number=contract, note=note)
|
||||
print("seed ok")
|
||||
Whitespace-only changes.
@@ -0,0 +1,44 @@
|
||||
"""Тесты идут против приложения в контейнерах (docker compose up -d) с учётными данными из .env."""
|
||||
import pathlib
|
||||
import random
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
ENV = dict(l.split("=", 1) for l in pathlib.Path(__file__).resolve().parent.parent.joinpath(".env").read_text().split() if "=" in l)
|
||||
BASE = f"http://127.0.0.1:{ENV['APP_PORT']}/api/v1"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client():
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
tok = c.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": ENV["ADMIN_PASSWORD"]}).json()["access_token"]
|
||||
c.headers["Authorization"] = f"Bearer {tok}"
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org(client):
|
||||
"""Временная организация с дефолтным VRF; после теста удаляется вместе с содержимым."""
|
||||
o = client.post("/organizations", json={"name": f"test-{uuid.uuid4().hex[:8]}", "inn": "".join(random.choices("0123456789", k=10))}).json()
|
||||
o["vrf_id"] = client.get("/vrfs", params={"organization_id": o["id"]}).json()["items"][0]["id"]
|
||||
yield o
|
||||
for p in client.get("/prefixes", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/prefixes/{p['id']}", params={"force": True})
|
||||
for d in client.get("/devices", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/devices/{d['id']}")
|
||||
for v in client.get("/vrfs", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/vrfs/{v['id']}")
|
||||
client.delete(f"/organizations/{o['id']}")
|
||||
|
||||
|
||||
DSN = f"postgresql://{ENV['POSTGRES_USER']}:{ENV['POSTGRES_PASSWORD']}@127.0.0.1:{ENV['DB_HOST_PORT']}/{ENV['POSTGRES_DB']}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""Прямое подключение к БД — для сценариев, недоступных через API (старые записи, временные пользователи)."""
|
||||
import psycopg
|
||||
with psycopg.connect(DSN, autocommit=True) as conn:
|
||||
yield conn
|
||||
@@ -0,0 +1,82 @@
|
||||
import httpx
|
||||
|
||||
from tests.conftest import BASE, ENV
|
||||
|
||||
|
||||
def _prefix(client, org, cidr, **kw):
|
||||
return client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr, **kw})
|
||||
|
||||
|
||||
def test_auth_required_and_login():
|
||||
anon = httpx.Client(base_url=BASE)
|
||||
assert anon.get("/prefixes").status_code == 401
|
||||
assert anon.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": "wrong"}).status_code == 401
|
||||
ok = anon.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": ENV["ADMIN_PASSWORD"]})
|
||||
assert ok.status_code == 200 and ok.json()["access_token"]
|
||||
|
||||
|
||||
def test_prefix_and_address_validation(client, org):
|
||||
assert _prefix(client, org, "10.201.0.0/24").status_code == 201
|
||||
assert _prefix(client, org, "10.201.0.0/24").status_code == 409 # дубль в VRF
|
||||
assert _prefix(client, org, "10.201.1.5/24").status_code == 422 # биты хоста
|
||||
pid = client.get("/prefixes", params={"organization_id": org["id"]}).json()["items"][0]["id"]
|
||||
assert client.post(f"/prefixes/{pid}/addresses", json={"address": "10.202.0.1"}).status_code == 422 # вне префикса
|
||||
|
||||
|
||||
def test_tree_utilization_and_next_free(client, org):
|
||||
parent = _prefix(client, org, "10.203.0.0/16").json()
|
||||
leaf = _prefix(client, org, "10.203.1.0/29", is_pool=True).json()
|
||||
assert leaf["parent_id"] == parent["id"]
|
||||
a = client.post(f"/prefixes/{leaf['id']}/addresses/next").json()
|
||||
assert a["address"] == "10.203.1.1"
|
||||
client.post(f"/prefixes/{leaf['id']}/addresses", json={"address": "10.203.1.2", "status": "reserved"})
|
||||
page = client.get(f"/prefixes/{leaf['id']}/addresses").json()
|
||||
assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6}
|
||||
assert client.post(f"/prefixes/{leaf['id']}/addresses/next").json()["address"] == "10.203.1.3"
|
||||
parent = client.get(f"/prefixes/{parent['id']}").json()
|
||||
assert parent["capacity"] == 6 and parent["used"] == 2 # ёмкость родителя — по вложенным листьям
|
||||
|
||||
|
||||
def test_in_use_objects_cannot_be_deleted(client, org):
|
||||
_prefix(client, org, "10.204.0.0/24")
|
||||
assert client.delete(f"/vrfs/{org['vrf_id']}").status_code == 409
|
||||
t = client.get("/device-types").json()["items"][0]
|
||||
client.post("/devices", json={"name": "t-1.internal", "device_type_id": t["id"], "organization_id": org["id"]})
|
||||
assert client.delete(f"/device-types/{t['id']}").status_code == 409
|
||||
|
||||
|
||||
def test_vrf_name_unique_per_organization(client, org):
|
||||
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
try:
|
||||
assert client.post("/vrfs", json={"organization_id": org["id"], "name": "Lab"}).status_code == 201
|
||||
assert client.post("/vrfs", json={"organization_id": org["id"], "name": "lab"}).status_code == 409 # регистр не важен
|
||||
assert client.post("/vrfs", json={"organization_id": other["id"], "name": "Lab"}).status_code == 201 # в другой организации можно
|
||||
finally:
|
||||
for v in client.get("/vrfs", params={"organization_id": other["id"]}).json()["items"]:
|
||||
client.delete(f"/vrfs/{v['id']}")
|
||||
client.delete(f"/organizations/{other['id']}")
|
||||
|
||||
|
||||
def test_move_prefix_subtree_to_another_vrf(client, org):
|
||||
lab = client.post("/vrfs", json={"organization_id": org["id"], "name": "lab"}).json()
|
||||
parent = _prefix(client, org, "10.205.0.0/16").json()
|
||||
child = _prefix(client, org, "10.205.1.0/24").json()
|
||||
assert child["parent_id"] == parent["id"]
|
||||
# конфликт в целевом VRF -> 409, ничего не переехало
|
||||
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": lab["id"], "prefix": "10.205.1.0/24"})
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": lab["id"]}).status_code == 409
|
||||
assert client.get(f"/prefixes/{child['id']}").json()["vrf_id"] == org["vrf_id"]
|
||||
# VRF другой организации -> 422
|
||||
foreign = client.post("/organizations", json={"name": f"f-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
foreign_vrf = client.get("/vrfs", params={"organization_id": foreign["id"]}).json()["items"][0]
|
||||
try:
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": foreign_vrf["id"]}).status_code == 422
|
||||
finally:
|
||||
client.delete(f"/vrfs/{foreign_vrf['id']}")
|
||||
client.delete(f"/organizations/{foreign['id']}")
|
||||
# без конфликта: переезжает поддерево, родитель пересчитан
|
||||
for p in client.get("/prefixes", params={"organization_id": org["id"], "vrf_id": lab["id"]}).json()["items"]:
|
||||
client.delete(f"/prefixes/{p['id']}")
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": lab["id"]}).status_code == 200
|
||||
moved = client.get(f"/prefixes/{child['id']}").json()
|
||||
assert moved["vrf_id"] == lab["id"] and moved["parent_id"] == parent["id"]
|
||||
@@ -0,0 +1,79 @@
|
||||
import ipaddress
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from app.request_context import parse_networks, resolve_client_ip
|
||||
from app.security import hash_password
|
||||
from tests.conftest import BASE
|
||||
|
||||
|
||||
def test_journal_search_and_filters(client, org):
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64" # уникальный для запуска: журнал общий и накапливается
|
||||
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
hit = client.get("/audit", params={"q": cidr}).json()
|
||||
assert hit["total"] >= 1
|
||||
entry = hit["items"][0]
|
||||
assert entry["event_type"] == "prefix.created" and entry["actor"] == "ui:admin" and "создан" in entry["message"]
|
||||
assert client.get("/audit", params={"q": "evt_" + entry["short_id"]}).json()["total"] >= 1 # поиск по ID
|
||||
assert client.get("/audit", params={"q": cidr, "event_type": "prefix.deleted"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"q": cidr, "actor": "system"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"q": cidr, "date_from": "2999-01-01"}).json()["total"] == 0
|
||||
assert client.get(f"/audit/{entry['uid']}").json()["id"] == entry["id"]
|
||||
assert client.get("/audit", params={"q": "100%_"}).json()["total"] == 0 # спецсимволы LIKE экранируются
|
||||
|
||||
|
||||
def test_rotation_by_age_and_count(client, db):
|
||||
old = db.execute("INSERT INTO audit_log (ts, username, entity_type, entity_label, action, message) "
|
||||
"VALUES (now() - interval '200 days', 'admin', 'prefix', 'rot-old', 'created', 'ROT-OLD') RETURNING uid").fetchone()[0]
|
||||
try:
|
||||
assert client.put("/journal/settings", json={"retention_days": 30, "max_entries": 100000}).json()["deleted"] >= 1
|
||||
assert client.get(f"/audit/{old}").status_code == 404 # старая запись ушла
|
||||
total = client.get("/audit/summary").json()["total"]
|
||||
r = client.put("/journal/settings", json={"retention_days": 30, "max_entries": total - 3}).json()
|
||||
assert r["deleted"] == 5 # 4 лишних (с учётом записи о смене настроек) + место под сводную запись
|
||||
assert client.get("/audit/summary").json()["total"] == total - 3
|
||||
assert client.get("/audit", params={"event_type": "journal.rotated"}).json()["total"] >= 1
|
||||
finally:
|
||||
client.put("/journal/settings", json={"retention_days": 90, "max_entries": 100000})
|
||||
|
||||
|
||||
def test_clear_requires_password_and_locks_out(db):
|
||||
name, pw = f"tmp-{uuid.uuid4().hex[:6]}", "tmp-pass-123"
|
||||
db.execute("INSERT INTO users (username, password_hash, role, is_active) VALUES (%s, %s, 'admin', true)", (name, hash_password(pw)))
|
||||
try:
|
||||
c = httpx.Client(base_url=BASE)
|
||||
c.headers["Authorization"] = "Bearer " + c.post("/auth/login", json={"username": name, "password": pw}).json()["access_token"]
|
||||
first = c.post("/journal/clear", json={"password": "wrong"})
|
||||
assert first.status_code == 403 and first.json()["attempts_left"] == 4
|
||||
codes = [c.post("/journal/clear", json={"password": "wrong"}).status_code for _ in range(4)]
|
||||
assert codes == [403, 403, 403, 429] # пятая неудача — блокировка
|
||||
locked = c.post("/journal/clear", json={"password": pw}) # даже верный пароль при блокировке отклоняется
|
||||
assert locked.status_code == 429 and locked.json()["retry_after_seconds"] > 0
|
||||
finally:
|
||||
db.execute("DELETE FROM users WHERE username = %s", (name,))
|
||||
|
||||
|
||||
def test_client_ip_and_request_meta_recorded(client, org):
|
||||
hdr = {"User-Agent": "ipam-tests/1.0", "X-Forwarded-For": "203.0.113.9"} # подделка XFF от недоверенного пира
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64"
|
||||
client.post("/prefixes", headers=hdr, json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
entry = client.get("/audit", params={"q": cidr}).json()["items"][0]
|
||||
assert ipaddress.ip_address(entry["client_ip"])
|
||||
assert entry["client_ip"] != "203.0.113.9" # заголовок не доверенного прокси игнорируется
|
||||
assert entry["meta"]["user_agent"] == "ipam-tests/1.0" and entry["meta"]["method"] == "POST" and entry["meta"]["path"] == "/api/v1/prefixes"
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": entry["client_ip"]}).json()["total"] >= 1 # точный IP
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": "203.0.113.0/24"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"client_ip": "не-ip"}).status_code == 422
|
||||
rotated = client.get("/audit", params={"event_type": "journal.rotated", "limit": 1}).json()["items"]
|
||||
assert all(r["client_ip"] is None for r in rotated) # системные события — без IP
|
||||
|
||||
|
||||
def test_resolve_client_ip_trusts_forwarded_header_only_from_proxies():
|
||||
trusted = parse_networks("10.0.0.0/8, 192.168.1.5")
|
||||
assert resolve_client_ip("198.51.100.7", "203.0.113.9", trusted) == "198.51.100.7" # недоверенный пир: XFF игнорируется
|
||||
assert resolve_client_ip("10.1.2.3", "203.0.113.9, 10.9.9.9", trusted) == "203.0.113.9" # справа первый недоверенный
|
||||
assert resolve_client_ip("10.1.2.3", "1.1.1.1, 203.0.113.9, 10.9.9.9", trusted) == "203.0.113.9" # клиент не может «подставить» левый адрес
|
||||
assert resolve_client_ip("10.1.2.3", "мусор", trusted) == "10.1.2.3"
|
||||
assert resolve_client_ip("::ffff:192.0.2.4", None, trusted) == "192.0.2.4" # IPv4-mapped
|
||||
assert resolve_client_ip(None, None, trusted) is None
|
||||
+833
@@ -0,0 +1,833 @@
|
||||
// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
|
||||
const API = "/api/v1";
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const fmtNum = (n) => new Intl.NumberFormat("ru-RU").format(n);
|
||||
const fmtDate = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) : "—");
|
||||
const plural = (n, one, few, many) => {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20) ? few : many;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ icons */
|
||||
const ic = (inner, s = 16) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
|
||||
const I = {
|
||||
chevR: (s) => ic('<path d="m9 6 6 6-6 6"/>', s),
|
||||
chevD: (s) => ic('<path d="m6 9 6 6 6-6"/>', s),
|
||||
plus: () => ic('<path d="M12 5v14M5 12h14"/>'),
|
||||
search: () => ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
|
||||
dots: () => ic('<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>'),
|
||||
close: () => ic('<path d="M6 6l12 12M18 6 6 18"/>'),
|
||||
edit: (s) => ic('<path d="M12 20h9M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4Z"/>', s),
|
||||
trash: (s) => ic('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', s),
|
||||
org: () => ic('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h4M7 13h4"/>'),
|
||||
gear: () => ic('<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2-1.6-2-3.4-2.3.9a7.4 7.4 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7.4 7.4 0 0 0-1.7 1l-2.3-.9-2 3.4 2 1.6a7.6 7.6 0 0 0 0 2l-2 1.6 2 3.4 2.3-.9a7.4 7.4 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7.4 7.4 0 0 0 1.7-1l2.3.9 2-3.4Z"/>'),
|
||||
refresh: () => ic('<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>'),
|
||||
cog: () => ic('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
|
||||
trashJ: () => ic('<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>'),
|
||||
calendar: () => ic('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
|
||||
copy: () => ic('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>'),
|
||||
globe: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17"/><path d="M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9"/><path d="M12 3a15 15 0 0 0-4 9 15 15 0 0 0 4 9"/></svg>`,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------- state */
|
||||
const store = {
|
||||
get token() { return sessionStorage.getItem("ipam.token"); },
|
||||
set token(v) { v ? sessionStorage.setItem("ipam.token", v) : sessionStorage.removeItem("ipam.token"); },
|
||||
get orgId() { return Number(localStorage.getItem("ipam.org")) || null; },
|
||||
set orgId(v) { localStorage.setItem("ipam.org", v); },
|
||||
};
|
||||
let user = null;
|
||||
let orgs = [];
|
||||
let S = {}; // локальное состояние текущего экрана
|
||||
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
|
||||
|
||||
/* --------------------------------------------------------------------- api */
|
||||
class ApiError extends Error {
|
||||
constructor(status, body) {
|
||||
super(body?.message || `Ошибка ${status}`);
|
||||
this.status = status;
|
||||
this.fields = body?.fields || {};
|
||||
this.body = body || {};
|
||||
}
|
||||
}
|
||||
async function api(path, { method = "GET", body, params } = {}) {
|
||||
const qs = params ? "?" + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== "" && v != null)) : "";
|
||||
const res = await fetch(API + path + qs, {
|
||||
method,
|
||||
headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(store.token ? { Authorization: "Bearer " + store.token } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.status === 401 && path !== "/auth/login") {
|
||||
store.token = null;
|
||||
location.hash = "#/login";
|
||||
throw new ApiError(401, { message: "Сессия истекла" });
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, data);
|
||||
return data;
|
||||
}
|
||||
function toast(msg, err = false) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast" + (err ? " err" : "");
|
||||
el.textContent = msg;
|
||||
$("#toast-root").append(el);
|
||||
setTimeout(() => el.remove(), 3500);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- shared fragments */
|
||||
const badge = (cls, text) => `<span class="badge ${cls}">${esc(text)}</span>`;
|
||||
const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] };
|
||||
const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] };
|
||||
const utilColor = (p) => (p >= 90 ? ["#c62828", "#a32020"] : p >= 75 ? ["#d97706", "#7c4a00"] : ["#2450c8", "#58657a"]);
|
||||
const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => `<button type="button" class="btn ${cls}" data-action="${action}" ${data}>${icon}${label}</button>`;
|
||||
const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =>
|
||||
`<button type="button" class="icon-btn${sm ? " sm" : ""}" data-action="${action}" aria-label="${esc(label)}" title="${esc(label)}" ${data} ${disabled ? "disabled" : ""}>${icon}</button>`;
|
||||
const searchBox = (value, placeholder, width) =>
|
||||
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
|
||||
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
|
||||
|
||||
function popMenu(id, items, extra = "") {
|
||||
if (menu?.id !== id) return "";
|
||||
return `<div class="pop ${extra}" role="menu">${items.map((it) => `<button type="button" role="menuitem" class="${it.cls || ""}" data-action="pick" data-menu="${id}" data-value="${esc(it.value)}">${esc(it.label)}</button>`).join("")}</div>`;
|
||||
}
|
||||
function filterBtn(id, label, items, value, width = 180) {
|
||||
const cur = items.find((i) => String(i.value) === String(value));
|
||||
return `<span class="rel"><button type="button" class="btn filter-btn" style="width:${width}px" data-action="menu" data-menu="${id}">${esc(label)}: ${esc(cur?.label ?? "любой")}${I.chevD(14)}</button>${popMenu(id, items.map((i) => ({ ...i, cls: String(i.value) === String(value) ? "sel" : "" })), "")}</span>`;
|
||||
}
|
||||
function orgSwitcher() {
|
||||
const o = currentOrg();
|
||||
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
|
||||
}
|
||||
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
|
||||
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
|
||||
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"]];
|
||||
function shell(active, content) {
|
||||
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
|
||||
<nav class="nav">${NAV.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div><span class="user">${esc(user?.username ?? "")}</span>${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
<div class="page">${content}</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ dialogs */
|
||||
function openDialog({ title, width = 560, note = "", body, foot }) {
|
||||
$("#modal-root").innerHTML = `<div class="overlay" data-action="overlay"><div class="dialog" style="width:${width}px" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||
<div class="dialog-head"><h2>${esc(title)}</h2>${iconBtn(I.close(), "close-dialog", "Закрыть")}</div>${note ? `<div class="dialog-note">${note}</div>` : ""}${body}${foot}</div></div>`;
|
||||
$("#dlg-form .input, #dlg-form .select")?.focus();
|
||||
}
|
||||
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; };
|
||||
const opt = (label) => `<span class="opt"> ${label}</span>`;
|
||||
const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) =>
|
||||
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}</span><input class="input${mono ? " mono" : ""}" type="${type}" name="${name}" value="${esc(value)}" placeholder="${esc(ph)}"></label>`;
|
||||
const fArea = (name, label, { value = "", ph = "Любая дополнительная информация", rows = 2, h = 56, mono = false, optional = true, hint = "" } = {}) =>
|
||||
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}${hint ? opt(hint) : ""}</span><textarea class="textarea${mono ? " mono" : ""}" name="${name}" rows="${rows}" placeholder="${esc(ph)}" style="height:${h}px${mono ? ";font-size:13px" : ""}">${esc(value)}</textarea></label>`;
|
||||
// Выпадающий список в диалогах — тот же стиль, что у меню строк (не нативный <select>); значение хранится в скрытом поле
|
||||
const fSelect = (name, label, options, value, { optional = false, hint = "", empty = "" } = {}) => {
|
||||
const all = empty !== "" ? [{ value: "", label: empty }, ...options] : options;
|
||||
const cur = all.find((o) => String(o.value) === String(value)) ?? all[0];
|
||||
return `<div class="field"><span>${label}${optional ? opt("необязательно") : ""}${hint ? opt(hint) : ""}</span>
|
||||
<div class="cselect"><input type="hidden" name="${name}" value="${esc(cur?.value ?? "")}">
|
||||
<button type="button" class="select${cur?.value === "" ? " ph" : ""}" data-action="dd-toggle" aria-haspopup="listbox" aria-label="${esc(label)}"><span class="v">${esc(cur?.label ?? "")}</span></button>
|
||||
<div class="pop dd" role="listbox" hidden>${all.map((o) => `<button type="button" role="option" class="${String(o.value) === String(cur?.value) ? "sel" : ""}" data-action="dd-pick" data-value="${esc(o.value)}" data-empty="${o.value === "" ? 1 : 0}">${esc(o.label)}</button>`).join("")}</div></div></div>`;
|
||||
};
|
||||
const dlgFoot = (submit) => `<div class="dialog-foot">${btn("Отмена", "close-dialog", { cls: "ghost" })}${btn(submit, "submit-form", { cls: "primary" })}</div>`;
|
||||
const formBody = (inner) => `<form class="dialog-body" id="dlg-form" novalidate><div class="err-banner" id="dlg-err" hidden></div>${inner}</form>`;
|
||||
|
||||
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 `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
|
||||
}).join("");
|
||||
const recent = o.recent_changes.map((r, i, a) => {
|
||||
const [c, t] = actionBadge[r.action] || ["", r.action];
|
||||
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:140px 1.2fr 1fr 130px;--h:48px;font-size:13px"><span class="muted">${fmtDate(r.ts)}</span><span class="${["prefix", "address"].includes(r.entity_type) ? "m13" : ""}" style="${["prefix", "address"].includes(r.entity_type) ? "" : "font-size:13px"}">${esc(r.entity_label)}</span><span>${badge(c, t)}</span><span class="muted">${esc(r.username)}</span></div>`;
|
||||
}).join("");
|
||||
return shell("overview", `${header("Обзор", "Сводка по адресному пространству")}
|
||||
<div class="stats">
|
||||
<div class="card stat"><div class="l">Префиксов</div><div class="v">${fmtNum(o.prefixes)}</div><div class="s">${o.vrfs} VRF</div></div>
|
||||
<div class="card stat"><div class="l">Адресов назначено</div><div class="v">${fmtNum(o.assigned)}</div><div class="s">из ${fmtNum(o.capacity)} возможных</div></div>
|
||||
<div class="card stat"><div class="l">Использование</div><div class="v">${o.utilization}%</div><div class="track"><div style="width:${o.utilization}%"></div></div></div>
|
||||
<div class="card stat"><div class="l">Зарезервировано</div><div class="v" style="color:#7c4a00">${fmtNum(o.reserved)}</div><div class="s">адресов</div></div></div>
|
||||
<div class="two"><div class="card"><div class="card-title">Высокая загрузка</div>
|
||||
<div class="tr th" style="--cols:2fr 1fr 1fr;--h:36px"><span>Префикс</span><span>Использовано</span><span></span></div>${top || '<div class="empty">Нет данных</div>'}</div>
|
||||
<div class="card"><div class="card-title">Последние изменения</div>
|
||||
<div class="tr th" style="--cols:140px 1.2fr 1fr 130px;--h:36px"><span>Дата (UTC)</span><span>Объект</span><span>Действие</span><span>Пользователь</span></div>${recent || '<div class="empty">Изменений пока нет</div>'}</div></div>`);
|
||||
};
|
||||
|
||||
// ---- 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) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px">
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
|
||||
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
|
||||
<span class="cell-actions rel">${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")}</span></div>`).join("");
|
||||
S.rows = items;
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
|
||||
<div class="tr th" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px"><span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
|
||||
};
|
||||
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 })}
|
||||
<div class="grid2">${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}</div>
|
||||
${fInput("address", "Юридический адрес", { value: o?.address })}
|
||||
<div class="grid2">${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}</div>
|
||||
${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) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}"><span style="font-weight:500">${esc(i.name)}</span>
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
|
||||
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
|
||||
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
|
||||
<span class="cell-actions rel">${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")}</span></div>`).join("");
|
||||
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
|
||||
<div class="tr th" style="--cols:${cols}"><span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
|
||||
};
|
||||
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: "можно несколько" })}
|
||||
<div class="grid2">${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}</div>
|
||||
${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
|
||||
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
|
||||
: `<span class="muted" style="font-size:13px">0</span>`;
|
||||
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}"><span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
|
||||
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
|
||||
<span class="cell-actions rel">${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")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
|
||||
<div class="tr th" style="--cols:${cols}"><span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
|
||||
};
|
||||
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" })}
|
||||
<div class="grid2">${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 })}</div>
|
||||
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
|
||||
${fArea("note", "Заметки", { value: d?.note })}
|
||||
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
|
||||
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
|
||||
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
|
||||
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
|
||||
const locked = t.devices_count > 0;
|
||||
const acts = t.is_default
|
||||
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
|
||||
: 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 `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
|
||||
}).join("");
|
||||
openDialog({
|
||||
title: "Управление типами устройств", width: 640,
|
||||
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.",
|
||||
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
|
||||
<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- prefixes
|
||||
screens.prefixes = async () => {
|
||||
const org = currentOrg();
|
||||
if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `<div class="card"><div class="empty">Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}</div></div>`);
|
||||
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) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
|
||||
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) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
|
||||
const pad = p.depth * 20;
|
||||
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
|
||||
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
|
||||
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
|
||||
<span class="cell-actions rel">${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") : ""}</span></div>`;
|
||||
}).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() }))}
|
||||
<div class="tabs">${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
|
||||
<div class="tr th" style="--cols:${cols};--h:40px"><span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</div></div>`);
|
||||
};
|
||||
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("</span><input", `${opt("например 10.30.0.0/24")}</span><input`)}
|
||||
${fInput("description", "Описание", { value: p?.description })}
|
||||
<div class="grid2">${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")}</div>
|
||||
${edit && nested ? `<div class="info" id="vrf-hint" hidden>Вместе с префиксом будет перенесено вложенных: ${nested}</div>` : ""}
|
||||
${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })}
|
||||
${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })}
|
||||
<label class="check"><input type="checkbox" name="is_pool" ${p?.is_pool ? "checked" : ""}><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически по запросу</small></span></label>`),
|
||||
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 `<div class="tr" style="--cols:${cols};--h:48px">${editing ? `<input class="input" id="edit-name" value="${esc(v.name)}" style="height:32px">` : `<span style="font-weight:600">${esc(v.name)}</span>`}
|
||||
${editing ? `<input class="input mono" id="edit-rt" value="${esc(v.route_target)}" style="height:32px;font-size:13px">` : `<span class="mono muted" style="font-size:13px">${esc(v.route_target)}</span>`}<span class="muted">${v.prefixes_count}</span><span style="display:flex;justify-content:flex-end;gap:4px">${acts}</span></div>`;
|
||||
}).join("");
|
||||
openDialog({
|
||||
title: "Управление VRF", width: 680,
|
||||
note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`,
|
||||
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Route target</span><span>Префиксов</span><span></span></div>${rows}</div>
|
||||
<div class="add-row"><input class="input" id="new-vrf" placeholder="Название нового VRF"><input class="input mono" id="new-rt" placeholder="Route target (необязательно)" style="width:180px;flex:none;font-size:13px"><button type="button" class="btn outline-primary" style="flex:none" data-action="vrf-add">${I.plus()}Добавить</button></div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Готово", "vrf-done", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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 `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px"><span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
|
||||
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
|
||||
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
|
||||
const shown = page.items.length;
|
||||
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
|
||||
const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }];
|
||||
return shell("prefixes", `<div class="crumbs" style="font-weight:400;margin-top:16px;padding-top:0"><a href="#/prefixes">Префиксы</a>${chain.map((x) => `${I.chevR(14)}<span>${esc(x.prefix)}</span>`).join("")}${I.chevR(14)}<span style="color:var(--text);font-weight:500">${esc(p.prefix)}</span></div>
|
||||
<div class="head" style="margin:12px 0 16px"><div><h1 class="mono" style="font-family:var(--sans)">${esc(p.prefix)}</h1><div class="sub">${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}</div></div>
|
||||
<div class="actions">${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}</div></div>
|
||||
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#2450c8"></div><div style="width:${pct(sm.reserved)}%;background:#d97706"></div></div></div>
|
||||
<div class="legend"><span><i style="background:#2450c8"></i>Назначено ${sm.assigned}</span><span><i style="background:#d97706"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:#dde3ea"></i>Свободно ${sm.free}</span></div></div>
|
||||
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
|
||||
<div class="tr th" style="--cols:${cols};--h:40px"><span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
|
||||
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
|
||||
};
|
||||
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
|
||||
? `<label class="field"><span>IP-адрес</span><input class="input mono" value="${esc(a.address)}" disabled></label>`
|
||||
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") });
|
||||
openDialog({
|
||||
title: edit ? "Редактировать адрес" : "Назначить адрес",
|
||||
body: formBody(`<div class="info">Подсеть: <b>${esc(p.prefix)}</b> · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}</div>
|
||||
<div class="grid2">${ipField}${fSelect("status", "Статус", [["assigned", "Назначен"], ["reserved", "Резерв"], ["deprecated", "Устаревший"]].map(([value, label]) => ({ value, label })), a?.status ?? "assigned")}</div>
|
||||
${fInput("dns_name", "DNS-имя", { value: a?.dns_name, mono: true, ph: "host.example.com", optional: true }).replace("необязательно", "необязательно, FQDN")}
|
||||
${fInput("description", "Описание", { value: a?.description })}
|
||||
<div class="field">${fSelect("device_id", "Связано с устройством", S.devices.map((d) => ({ value: d.id, label: d.name })), a?.device_id ?? "", { optional: true, empty: "Выберите устройство…" }).replace('<label class="field">', "<label class=\"field\" style=\"gap:6px\">")}<a href="#/devices" style="align-self:flex-end;margin-top:-4px;font:500 12px var(--sans)">Управление устройствами →</a></div>
|
||||
${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) => `<div class="tr hoverable clickable" style="--cols:${cols};--h:48px" data-action="jr-open" data-uid="${r.uid}" role="button" tabindex="0">
|
||||
<span class="muted" style="font-size:13px;white-space:nowrap">${fmtDateSec(r.ts)}</span><span>${eventBadge(r.event_type)}</span>
|
||||
<span style="display:flex;gap:8px;align-items:baseline;min-width:0"><span>${esc(ENTITY_RU[r.entity_type] || r.entity_type)}</span>${["session", "journal"].includes(r.entity_type) ? "" : `<span class="mono muted ell" style="font-size:13px" title="${esc(r.entity_label)}">${esc(r.entity_label)}</span>`}</span>
|
||||
<span class="ell" title="${esc(r.message)}">${esc(r.message)}</span><span class="muted" style="font-size:13px">${esc(r.actor)}</span><span class="mono muted" style="font-size:13px">${esc(r.client_ip || "—")}</span><span class="mono muted" style="font-size:13px">${r.short_id}</span></div>`).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) => `<label class="datebox"><span class="muted">${label}</span><input type="date" id="${id}" data-input="${id}" value="${esc(v || "")}" aria-label="${label === "с" ? "Период с" : "Период по"}"><span class="cal">${I.calendar()}</span></label>`;
|
||||
return shell("journal", `${header("Журнал", `${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sum.oldest_ts ? fmtDate(sum.oldest_ts) : "—"} · ${rot}`, act)}
|
||||
<div class="card"><div class="filters">${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)}</div>
|
||||
<div class="tr th" style="--cols:${cols};--h:40px"><span>Время (UTC)</span><span>Тип</span><span>Сущность</span><span>Сообщение</span><span>Актор</span><span>IP-адрес</span><span>ID записи</span></div>
|
||||
${rows || '<div class="empty">Записей нет</div>'}
|
||||
${page.total > page.items.length ? `<div style="display:flex;justify-content:center;align-items:center;height:64px;border-bottom:1px solid var(--line)">${btn("Показать ещё 100", "more")}</div>` : ""}
|
||||
<div class="foot">Показано ${page.items.length} из ${fmtNum(page.total)} ${plural(page.total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
|
||||
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) => `<div class="kv"><span class="muted" style="font-size:13px">${label}</span><div style="display:flex;align-items:center;gap:8px;min-width:0">${inner}</div></div>`;
|
||||
const mono = (v) => `<span class="mono" style="font-size:13px;overflow-wrap:anywhere">${esc(v)}</span>`;
|
||||
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: `<div class="dialog-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) : '<span class="muted">—</span>')}${row("Сущность", entity)}${row("Сообщение", esc(r.message))}
|
||||
${r.meta?.method ? row("Запрос", mono(`${r.meta.method} ${r.meta.path}`)) : ""}${r.meta?.user_agent ? row("User-Agent", `<span class="muted" style="font-size:13px;overflow-wrap:anywhere">${esc(r.meta.user_agent)}</span>`) : ""}
|
||||
<div style="font:500 13px/1.3 var(--sans);margin-top:4px">Данные</div><pre class="json">${r.diff ? esc(JSON.stringify(r.diff, null, 2)) : "—"}</pre></div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog")}</div>`,
|
||||
});
|
||||
}
|
||||
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(`<div class="statbox">Сейчас в журнале: <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> · старейшая ${sm.oldest_ts ? fmtDate(sm.oldest_ts) : "—"}</div>
|
||||
${fInput("retention_days", "Хранить записи, дней", { value: sm.retention_days, type: "number", hint: "0 — без ограничения по сроку" }).replace("</span><input", `${opt("0 — без ограничения по сроку")}</span><input min="0" max="3650" step="1"`)}
|
||||
${fInput("max_entries", "Максимум записей", { value: sm.max_entries, type: "number" }).replace("</span><input", `${opt("0 — без ограничения по количеству")}</span><input min="0" max="10000000" step="1"`)}
|
||||
<div class="info">Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.</div>`),
|
||||
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: `<div class="dialog-body" id="clear-form"><div class="warn">Будет удалено <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.</div>
|
||||
<label class="field"><span>Пароль пользователя <b style="font-weight:600">${esc(user.username)}</b>${opt("подтверждение нужно только для этого действия")}</span>
|
||||
<input class="input${state.wrong ? " bad" : ""}" type="password" id="clear-pw" autocomplete="current-password" placeholder="${locked ? "" : "Введите пароль"}" ${locked ? "disabled" : ""} value=""></label>
|
||||
${state.wrong ? `<div style="font:500 13px/1.4 var(--sans);color:#a32020">Неверный пароль. Осталось попыток: ${state.left}</div>` : ""}
|
||||
${locked ? `<div class="err-banner">Слишком много неверных попыток. Повторите через ${minutes} мин.</div>` : ""}</div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Отмена", "close-dialog", { cls: "ghost" })}<button type="button" class="btn danger" id="clear-go" data-action="jr-clear-do" disabled>${I.trashJ()}Очистить журнал</button></div>`,
|
||||
});
|
||||
$("#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 `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
|
||||
<form id="login-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
|
||||
<label class="field"><span>Логин</span><input class="input" name="username" autocomplete="username" autofocus></label>
|
||||
<label class="field"><span>Пароль</span><input class="input" type="password" name="password" autocomplete="current-password"></label>
|
||||
<button class="btn primary" type="submit">Войти</button></form></div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- 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, `<div class="card" style="margin-top:24px"><div class="empty">Не удалось загрузить данные: ${esc(e.message)}</div></div>`);
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IPAM Manager</title>
|
||||
<link rel="stylesheet" href="fonts/fonts.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="modal-root"></div>
|
||||
<div id="toast-root"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+184
@@ -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}
|
||||
Reference in new issue
Block a user