Files
mont_vendor_maps/main.py

1814 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
import json
import sqlite3
from pathlib import Path
from typing import Iterable
from flask import Flask, jsonify, redirect, render_template_string, request, send_from_directory, session
try:
from openpyxl import load_workbook
except ImportError:
load_workbook = None
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "matrix.db"
XLSX_PATH = BASE_DIR / "Z-card_РФ.xlsx"
INFRA_JSON_FILES = [BASE_DIR / "infra1", BASE_DIR / "infra2", BASE_DIR / "infra3", BASE_DIR / "infra4"]
ADMIN_PATH = "/sdjlkfhsjkadahjksdhjgfkhsssssdjkjfljsdfjklsdajfkldsjflksdjfkldsj"
ADMIN_LOGIN = "batman"
ADMIN_PASSWORD = "batmannotmont"
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY", "change-me-please")
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def seed_data(conn: sqlite3.Connection) -> None:
categories = [
"Augmented Reality",
"IoT",
"Robotic Process Automation (RPA)",
"Автоматизация бизнес-процессов",
"Видеосвязь и веб-конференции",
"Визуализация и анализ данных",
"Виртуализация",
"Виртуализация рабочих мест, VDI",
"Виртуализация, гиперконвергенция",
"Виртуализация, классическая виртуализация",
"Графические редакторы (замена Visio)",
"Компьютерная техника",
"Контейнерные платформы",
"Корпоративные почтовые серверы",
"Корпоративные коммуникации",
"Облачные платформы",
"Облачные сервисы и сопроводительные решения",
"Онлайн-переводчик",
"Операционные системы",
"СУБД",
"Оцифровка бумажных документов",
"Платформы для онлайн-обучения",
"ПО в сфере ИИ",
"Программные маркетплейсы",
"Программы для смартфонов",
"Работа с PDF",
"Работа с мультимедиа (видео, фото, графика)",
"Разработка в ИИ",
"Резервное копирование и управление данными",
"Речевые технологии, компьютерное зрение",
"САПР",
"Серверное и WiFi оборудование",
"Системы хранения данных",
"Системы ЭДО",
"Средства разработки ПО",
"Техническая поддержка и консалтинг",
"Удаленное управление устройствами",
"Файлы и диски",
]
vendors = [
"Adobe",
"AliveColors",
"Amazon Web Services (AWS)",
"ANWORK",
"CommuniGate Pro (СБК)",
"Content AI (ex-ABBYY)",
"DocTrix",
"EvaTeam",
"eXpress",
"FanRuan",
"GStarCAD",
"Handy Backup",
"InfoWatch",
"iSpring",
"Just AI",
"Kairos Digital",
"LITEBIM",
"LiteManager",
"livedigital",
"Master PDF (Code Industry)",
"MIND Software",
"Monq",
"NextBox",
"Paragon Software Group",
"SL Soft",
"Positive Technologies",
"Postgres Pro",
"Pragmatic Tools",
"Pro32",
"PROMT",
"Quasar",
"Radmin",
"RDW Computers",
"Renga Software",
"SETERE Group",
"Sharx DC",
"SpaceVM",
"TestIT",
"Uncom OS",
"Utinet",
"Valo Cloud",
"Vinteo",
"VK Tech",
"АЛМИ Партнер",
"АСКОН",
"Базальт",
"БФТ",
"ГазИнформСервис",
"Гравитон",
"ГрафТех",
"Группа Астра",
"ИТ Роут",
"Киберпротект",
"Контур",
"Кредо-Диалог",
"Лаборатория Касперского",
"Лаборатория Числитель",
"Мовавика",
"МойОфис",
"МТС Линк",
"Нанософт разработка",
"НЛПК",
"Облакотека",
"Р7",
"РЕД СОФТ",
"РОСА",
"Росплатформа",
"Сакура ПРО",
"Салют для бизнеса (SberDevices)",
"Труконф",
"Флант (Deckhouse)",
"ЦРТ",
"ЦИТИП",
"Яндекс 360 для бизнеса",
]
vendor_links = {
"Adobe": ["Работа с PDF", "Оцифровка бумажных документов", "Работа с мультимедиа (видео, фото, графика)"],
"Amazon Web Services (AWS)": ["Облачные платформы", "Облачные сервисы и сопроводительные решения", "ПО в сфере ИИ"],
"CommuniGate Pro (СБК)": ["Корпоративные почтовые серверы", "Корпоративные коммуникации"],
"Content AI (ex-ABBYY)": ["Оцифровка бумажных документов", "Онлайн-переводчик", "Работа с PDF"],
"eXpress": ["Корпоративные коммуникации", "Программы для смартфонов"],
"FanRuan": ["Визуализация и анализ данных"],
"GStarCAD": ["САПР"],
"Handy Backup": ["Резервное копирование и управление данными"],
"iSpring": ["Платформы для онлайн-обучения"],
"Just AI": ["ПО в сфере ИИ", "Речевые технологии, компьютерное зрение"],
"LiteManager": ["Удаленное управление устройствами"],
"Master PDF (Code Industry)": ["Работа с PDF"],
"Paragon Software Group": ["Файлы и диски", "Резервное копирование и управление данными"],
"Postgres Pro": ["СУБД"],
"PROMT": ["Онлайн-переводчик"],
"Radmin": ["Удаленное управление устройствами"],
"Renga Software": ["САПР"],
"SpaceVM": ["Виртуализация", "Виртуализация рабочих мест, VDI"],
"Uncom OS": ["Операционные системы"],
"VK Tech": ["Облачные платформы", "Корпоративные коммуникации", "ПО в сфере ИИ"],
"Базальт": ["Операционные системы"],
"ГазИнформСервис": ["Системы ЭДО", "Техническая поддержка и консалтинг"],
"Группа Астра": ["Операционные системы", "Виртуализация", "СУБД"],
"Киберпротект": ["Резервное копирование и управление данными"],
"Контур": ["Системы ЭДО", "Корпоративные коммуникации"],
"Лаборатория Касперского": ["Техническая поддержка и консалтинг", "Средства разработки ПО"],
"МойОфис": ["Корпоративные коммуникации", "Программы для смартфонов", "Файлы и диски"],
"МТС Линк": ["Видеосвязь и веб-конференции", "Платформы для онлайн-обучения"],
"Р7": ["Корпоративные коммуникации", "Файлы и диски"],
"РЕД СОФТ": ["Операционные системы", "СУБД"],
"РОСА": ["Операционные системы"],
"Росплатформа": ["Облачные платформы", "Виртуализация, гиперконвергенция"],
"Салют для бизнеса (SberDevices)": ["ПО в сфере ИИ", "Речевые технологии, компьютерное зрение"],
"Труконф": ["Видеосвязь и веб-конференции", "Корпоративные коммуникации"],
"Флант (Deckhouse)": ["Контейнерные платформы", "Облачные платформы"],
"ЦРТ": ["Речевые технологии, компьютерное зрение", "ПО в сфере ИИ"],
"Яндекс 360 для бизнеса": ["Корпоративные коммуникации", "Файлы и диски", "Программы для смартфонов"],
}
conn.executemany("INSERT INTO categories(name) VALUES (?)", [(name,) for name in categories])
conn.executemany("INSERT INTO vendors(name) VALUES (?)", [(name,) for name in vendors])
category_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM categories")}
vendor_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM vendors")}
pairs: list[tuple[int, int]] = []
for vendor, cats in vendor_links.items():
v_id = vendor_ids.get(vendor)
if not v_id:
continue
for cat in cats:
c_id = category_ids.get(cat)
if c_id:
pairs.append((v_id, c_id))
conn.executemany(
"INSERT OR IGNORE INTO vendor_categories(vendor_id, category_id) VALUES (?, ?)",
pairs,
)
def init_db() -> None:
conn = get_db()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS vendors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS vendor_categories (
vendor_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (vendor_id, category_id),
FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE,
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ib_vendors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS ib_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS ib_vendor_categories (
vendor_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (vendor_id, category_id),
FOREIGN KEY(vendor_id) REFERENCES ib_vendors(id) ON DELETE CASCADE,
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_id INTEGER NOT NULL,
name TEXT NOT NULL,
url TEXT,
UNIQUE(vendor_id, name),
FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS product_categories (
product_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (product_id, category_id),
FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE,
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ib_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_id INTEGER NOT NULL,
name TEXT NOT NULL,
url TEXT,
UNIQUE(vendor_id, name),
FOREIGN KEY(vendor_id) REFERENCES ib_vendors(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ib_product_categories (
product_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (product_id, category_id),
FOREIGN KEY(product_id) REFERENCES ib_products(id) ON DELETE CASCADE,
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
);
"""
)
try:
conn.execute("ALTER TABLE products ADD COLUMN url TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE ib_products ADD COLUMN url TEXT")
except sqlite3.OperationalError:
pass
has_data = conn.execute("SELECT EXISTS(SELECT 1 FROM vendors)").fetchone()[0]
if not has_data:
seed_data(conn)
has_ib_data = conn.execute("SELECT EXISTS(SELECT 1 FROM ib_vendors)").fetchone()[0]
if not has_ib_data:
ib_matrix = None
from_xlsx = load_matrices_from_xlsx()
if from_xlsx:
ib_matrix = from_xlsx.get("ib")
if not ib_matrix:
ib_matrix = IB_MATRIX
seed_ib_data(conn, ib_matrix)
bootstrap_products_from_vendor_links(conn, "infra")
bootstrap_products_from_vendor_links(conn, "ib")
import_infra_products_from_json(conn)
conn.commit()
conn.close()
def fetch_matrix() -> dict:
conn = get_db()
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM vendors ORDER BY lower(name)")]
categories = [dict(r) for r in conn.execute("SELECT id, name FROM categories ORDER BY lower(name)")]
links = [dict(r) for r in conn.execute("SELECT vendor_id, category_id FROM vendor_categories")]
conn.close()
return {"vendors": vendors, "categories": categories, "links": links}
def scope_tables(scope: str) -> dict[str, str]:
if scope == "ib":
return {
"vendors": "ib_vendors",
"categories": "ib_categories",
"vendor_categories": "ib_vendor_categories",
"products": "ib_products",
"product_categories": "ib_product_categories",
}
return {
"vendors": "vendors",
"categories": "categories",
"vendor_categories": "vendor_categories",
"products": "products",
"product_categories": "product_categories",
}
def seed_ib_data(conn: sqlite3.Connection, matrix: dict) -> None:
categories = [item["name"] for item in matrix.get("categories", [])]
vendors = [item["name"] for item in matrix.get("vendors", [])]
links = matrix.get("links", [])
conn.executemany("INSERT OR IGNORE INTO ib_categories(name) VALUES (?)", [(name,) for name in categories])
conn.executemany("INSERT OR IGNORE INTO ib_vendors(name) VALUES (?)", [(name,) for name in vendors])
category_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM ib_categories")}
vendor_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM ib_vendors")}
src_category_by_id = {item["id"]: item["name"] for item in matrix.get("categories", [])}
src_vendor_by_id = {item["id"]: item["name"] for item in matrix.get("vendors", [])}
pairs: list[tuple[int, int]] = []
for link in links:
src_vendor_name = src_vendor_by_id.get(link["vendor_id"])
src_category_name = src_category_by_id.get(link["category_id"])
if not src_vendor_name or not src_category_name:
continue
db_vendor_id = vendor_ids.get(src_vendor_name)
db_category_id = category_ids.get(src_category_name)
if db_vendor_id and db_category_id:
pairs.append((db_vendor_id, db_category_id))
conn.executemany(
"INSERT OR IGNORE INTO ib_vendor_categories(vendor_id, category_id) VALUES (?, ?)",
pairs,
)
def fetch_ib_matrix() -> dict:
conn = get_db()
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM ib_vendors ORDER BY lower(name)")]
categories = [dict(r) for r in conn.execute("SELECT id, name FROM ib_categories ORDER BY lower(name)")]
links = [dict(r) for r in conn.execute("SELECT vendor_id, category_id FROM ib_vendor_categories")]
conn.close()
return {"vendors": vendors, "categories": categories, "links": links}
def fetch_scope_data(scope: str) -> dict:
tables = scope_tables(scope)
conn = get_db()
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']} ORDER BY lower(name)")]
categories = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['categories']} ORDER BY lower(name)")]
products = [
dict(r)
for r in conn.execute(
f"""
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
, p.url
FROM {tables['products']} p
JOIN {tables['vendors']} v ON v.id = p.vendor_id
ORDER BY lower(v.name), lower(p.name)
"""
)
]
product_links = [
dict(r)
for r in conn.execute(
f"SELECT product_id, category_id FROM {tables['product_categories']}"
)
]
links = [
dict(r)
for r in conn.execute(
f"SELECT vendor_id, category_id FROM {tables['vendor_categories']}"
)
]
conn.close()
return {
"vendors": vendors,
"categories": categories,
"products": products,
"product_links": product_links,
"links": links,
}
def bootstrap_products_from_vendor_links(conn: sqlite3.Connection, scope: str) -> None:
tables = scope_tables(scope)
has_products = conn.execute(f"SELECT EXISTS(SELECT 1 FROM {tables['products']})").fetchone()[0]
if has_products:
return
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']}")]
for vendor in vendors:
cur = conn.execute(
f"INSERT INTO {tables['products']}(vendor_id, name) VALUES (?, ?)",
(vendor["id"], "Базовый продукт"),
)
product_id = cur.lastrowid
categories = [
r["category_id"]
for r in conn.execute(
f"SELECT category_id FROM {tables['vendor_categories']} WHERE vendor_id = ?",
(vendor["id"],),
)
]
conn.executemany(
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
[(product_id, c_id) for c_id in categories],
)
def import_infra_products_from_json(conn: sqlite3.Connection) -> None:
present_files = [p for p in INFRA_JSON_FILES if p.exists()]
if not present_files:
return
marker_exists = conn.execute("SELECT EXISTS(SELECT 1 FROM products WHERE url IS NOT NULL AND trim(url) <> '')").fetchone()[0]
if marker_exists:
return
tables = scope_tables("infra")
vendors = {r["name"]: r["id"] for r in conn.execute(f"SELECT id, name FROM {tables['vendors']}")}
categories = {r["name"]: r["id"] for r in conn.execute(f"SELECT id, name FROM {tables['categories']}")}
imported_products = 0
imported_links = 0
skipped = 0
for path in present_files:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(payload, list):
continue
for item in payload:
if not isinstance(item, dict):
continue
vendor_name = (item.get("vendor") or "").strip()
product_name = (item.get("product") or "").strip()
if not vendor_name or not product_name:
skipped += 1
continue
if "нет подтвержденного соответствия" in product_name.lower():
skipped += 1
continue
vendor_id = vendors.get(vendor_name)
if not vendor_id:
skipped += 1
continue
product_url = ""
evidence = item.get("evidence") or []
if isinstance(evidence, list):
for entry in evidence:
if isinstance(entry, dict):
url = (entry.get("url") or "").strip()
if url:
product_url = url
break
conn.execute(
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)",
(vendor_id, product_name, product_url or None),
)
conn.execute(
f"UPDATE {tables['products']} SET url = COALESCE(NULLIF(url, ''), ?) WHERE vendor_id = ? AND name = ?",
(product_url or None, vendor_id, product_name),
)
product_id_row = conn.execute(
f"SELECT id FROM {tables['products']} WHERE vendor_id = ? AND name = ?",
(vendor_id, product_name),
).fetchone()
if not product_id_row:
skipped += 1
continue
product_id = product_id_row["id"]
imported_products += 1
category_names = item.get("categories") or []
if isinstance(category_names, list):
for category_name_raw in category_names:
category_name = str(category_name_raw).strip()
category_id = categories.get(category_name)
if not category_id:
continue
conn.execute(
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
(product_id, category_id),
)
imported_links += 1
conn.execute(f"DELETE FROM {tables['vendor_categories']}")
conn.execute(
f"""
INSERT OR IGNORE INTO {tables['vendor_categories']}(vendor_id, category_id)
SELECT DISTINCT p.vendor_id, pc.category_id
FROM {tables['products']} p
JOIN {tables['product_categories']} pc ON pc.product_id = p.id
"""
)
if imported_products == 0 and skipped > 0:
# Preserve non-empty startup state if JSON couldn't be mapped.
bootstrap_products_from_vendor_links(conn, "infra")
def build_matrix_from_lists(
vendors: list[str],
categories: list[str],
vendor_links: dict[str, list[str]],
) -> dict:
categories_payload = [{"id": i + 1, "name": name} for i, name in enumerate(categories)]
vendors_payload = [{"id": i + 1, "name": name} for i, name in enumerate(vendors)]
category_ids = {item["name"]: item["id"] for item in categories_payload}
vendor_ids = {item["name"]: item["id"] for item in vendors_payload}
links_payload: list[dict[str, int]] = []
for vendor_name, linked_categories in vendor_links.items():
v_id = vendor_ids.get(vendor_name)
if not v_id:
continue
for category_name in linked_categories:
c_id = category_ids.get(category_name)
if c_id:
links_payload.append({"vendor_id": v_id, "category_id": c_id})
return {"vendors": vendors_payload, "categories": categories_payload, "links": links_payload}
def parse_xlsx_matrix_sheet(
sheet,
*,
header_row: int,
data_start_row: int,
category_start_col: int,
) -> dict:
category_cols: list[tuple[int, str]] = []
for col in range(category_start_col, sheet.max_column + 1):
raw = sheet.cell(header_row, col).value
if raw is None:
continue
name = str(raw).strip()
if name:
category_cols.append((col, name))
categories_payload = [{"id": i + 1, "name": name} for i, (_, name) in enumerate(category_cols)]
category_id_by_col = {col: idx + 1 for idx, (col, _) in enumerate(category_cols)}
vendors_payload: list[dict[str, str | int]] = []
links_payload: list[dict[str, int]] = []
for row in range(data_start_row, sheet.max_row + 1):
raw_vendor = sheet.cell(row, 1).value
if raw_vendor is None:
continue
vendor_name = str(raw_vendor).strip()
if not vendor_name:
continue
lowered = vendor_name.lower()
if "вендор" in lowered or "решение" in lowered or "категория" in lowered:
continue
vendor_id = len(vendors_payload) + 1
vendors_payload.append({"id": vendor_id, "name": vendor_name})
for col, _ in category_cols:
mark = sheet.cell(row, col).value
if mark is None:
continue
if str(mark).strip() == "":
continue
links_payload.append({"vendor_id": vendor_id, "category_id": category_id_by_col[col]})
return {"vendors": vendors_payload, "categories": categories_payload, "links": links_payload}
def load_matrices_from_xlsx() -> dict[str, dict] | None:
if load_workbook is None:
return None
if not XLSX_PATH.exists():
return None
wb = load_workbook(XLSX_PATH, data_only=True)
if "инфра" not in wb.sheetnames or "инфобез" not in wb.sheetnames:
return None
infra = parse_xlsx_matrix_sheet(
wb["инфра"],
header_row=1,
data_start_row=2,
category_start_col=4,
)
ib = parse_xlsx_matrix_sheet(
wb["инфобез"],
header_row=2,
data_start_row=4,
category_start_col=3,
)
return {"infra": infra, "ib": ib}
IB_CATEGORIES = [
"Защита конечных устройств (EDR/EPP)",
"Безопасность мобильных устройств",
"Межсетевые экраны и NGFW",
"Удаленный доступ (VPN)",
"Защита от DDoS",
"Защита виртуальных сред",
"NTA / анализ сетевого трафика",
"Защита АСУ ТП",
"Sandbox",
"Управление уязвимостями (VM)",
"Управление событиями (SIEM)",
"SOAR",
"SGRC / комплаенс",
"Поведенческий анализ (UEBA)",
"Антифрод",
"KMS / криптозащита",
"DLP",
"Классификация и маркировка данных",
"Защита баз данных",
"DRM",
"DAM / доступ к секретам",
"Биометрическая аутентификация",
"MFA",
"Менеджер паролей",
"SWG / веб-безопасность",
"Родительский контроль",
]
IB_VENDORS = [
"Bifit Mitigator",
"BI.ZONE",
"Check Point",
"F6",
"InfoWatch",
"Positive Technologies",
"Лаборатория Касперского",
"Киберпротект",
"Код Безопасности",
"Р7",
"Контур",
"UserGate",
"С-Терра",
"Гарда",
"КриптоПро",
"Эшелон",
"R-Vision",
"RuSIEM",
"SkyDNS",
"IKOD",
"StaffCop",
"Zecurion",
"Nano Security",
"StopPhish",
]
IB_VENDOR_LINKS = {
"Bifit Mitigator": ["Антифрод", "UEBA", "SIEM"],
"BI.ZONE": ["SIEM", "SOAR", "SGRC / комплаенс", "VM", "DLP", "Антифрод"],
"Check Point": ["Межсетевые экраны и NGFW", "VPN", "Защита конечных устройств (EDR/EPP)", "SWG / веб-безопасность"],
"F6": ["Антифрод", "Защита от DDoS", "NTA / анализ сетевого трафика"],
"InfoWatch": ["DLP", "Классификация и маркировка данных", "DRM"],
"Positive Technologies": ["VM", "NTA / анализ сетевого трафика", "SIEM", "SOAR", "SGRC / комплаенс"],
"Лаборатория Касперского": ["Защита конечных устройств (EDR/EPP)", "Sandbox", "SIEM", "SWG / веб-безопасность"],
"Киберпротект": ["DLP", "Защита баз данных", "DRM"],
"Код Безопасности": ["Межсетевые экраны и NGFW", "VPN", "Защита виртуальных сред", "KMS / криптозащита"],
"Р7": ["MFA", "Менеджер паролей"],
"Контур": ["MFA", "Биометрическая аутентификация"],
"UserGate": ["Межсетевые экраны и NGFW", "SWG / веб-безопасность", "VPN"],
"С-Терра": ["VPN", "KMS / криптозащита"],
"Гарда": ["DLP", "Классификация и маркировка данных", "SIEM"],
"КриптоПро": ["KMS / криптозащита", "MFA", "Биометрическая аутентификация"],
"Эшелон": ["VM", "SIEM", "SGRC / комплаенс"],
"R-Vision": ["SIEM", "SOAR", "SGRC / комплаенс", "UEBA"],
"RuSIEM": ["SIEM", "SOAR"],
"SkyDNS": ["SWG / веб-безопасность", "Родительский контроль"],
"IKOD": ["DAM / доступ к секретам", "Менеджер паролей"],
"StaffCop": ["UEBA", "DLP"],
"Zecurion": ["DLP", "Классификация и маркировка данных", "Защита баз данных"],
"Nano Security": ["Защита конечных устройств (EDR/EPP)", "Sandbox"],
"StopPhish": ["Антифрод", "MFA"],
}
IB_MATRIX = build_matrix_from_lists(IB_VENDORS, IB_CATEGORIES, IB_VENDOR_LINKS)
def require_admin() -> bool:
return bool(session.get("is_admin"))
def parse_int(form_value: str | None) -> int | None:
if not form_value:
return None
try:
return int(form_value)
except ValueError:
return None
@app.get("/")
def index():
return render_template_string(INDEX_HTML)
@app.get("/api/data")
def api_data():
scope = (request.args.get("scope") or "infra").strip().lower()
if scope in {"ib", "sec", "security"}:
scope = "ib"
else:
scope = "infra"
return jsonify(fetch_scope_data(scope))
@app.route(ADMIN_PATH, methods=["GET", "POST"])
def admin_login_or_panel():
conn = get_db()
raw_scope = (request.args.get("scope") or request.form.get("scope") or "infra").strip().lower()
scope = "ib" if raw_scope in {"ib", "sec", "security"} else "infra"
tables = scope_tables(scope)
if request.method == "POST" and not require_admin() and request.form.get("action") == "login":
if request.form.get("username") == ADMIN_LOGIN and request.form.get("password") == ADMIN_PASSWORD:
session["is_admin"] = True
conn.close()
return redirect(ADMIN_PATH)
conn.close()
return render_template_string(LOGIN_HTML, error="Неверный логин или пароль")
if not require_admin():
conn.close()
return render_template_string(LOGIN_HTML, error=None)
if request.method == "POST":
action = request.form.get("action", "")
if action == "logout":
session.pop("is_admin", None)
conn.close()
return redirect(ADMIN_PATH)
if action == "add_vendor":
name = (request.form.get("name") or "").strip()
if name:
conn.execute(f"INSERT OR IGNORE INTO {tables['vendors']}(name) VALUES (?)", (name,))
elif action == "add_category":
name = (request.form.get("name") or "").strip()
if name:
conn.execute(f"INSERT OR IGNORE INTO {tables['categories']}(name) VALUES (?)", (name,))
elif action == "add_product":
vendor_id = parse_int(request.form.get("vendor_id"))
name = (request.form.get("name") or "").strip()
url = (request.form.get("url") or "").strip()
if vendor_id and name:
conn.execute(
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)",
(vendor_id, name, url or None),
)
if url:
conn.execute(
f"UPDATE {tables['products']} SET url = ? WHERE vendor_id = ? AND name = ?",
(url, vendor_id, name),
)
elif action == "delete_vendor":
v_id = parse_int(request.form.get("vendor_id"))
if v_id:
conn.execute(f"DELETE FROM {tables['vendors']} WHERE id = ?", (v_id,))
elif action == "delete_category":
c_id = parse_int(request.form.get("category_id"))
if c_id:
conn.execute(f"DELETE FROM {tables['categories']} WHERE id = ?", (c_id,))
elif action == "delete_product":
p_id = parse_int(request.form.get("product_id"))
if p_id:
conn.execute(f"DELETE FROM {tables['products']} WHERE id = ?", (p_id,))
elif action == "save_matrix":
products = [r["id"] for r in conn.execute(f"SELECT id FROM {tables['products']}")]
categories = [r["id"] for r in conn.execute(f"SELECT id FROM {tables['categories']}")]
new_pairs: list[tuple[int, int]] = []
for p_id in products:
for c_id in categories:
if request.form.get(f"pc_{p_id}_{c_id}"):
new_pairs.append((p_id, c_id))
conn.execute(f"DELETE FROM {tables['product_categories']}")
conn.executemany(
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
new_pairs,
)
conn.execute(f"DELETE FROM {tables['vendor_categories']}")
conn.execute(
f"""
INSERT OR IGNORE INTO {tables['vendor_categories']}(vendor_id, category_id)
SELECT DISTINCT p.vendor_id, pc.category_id
FROM {tables['products']} p
JOIN {tables['product_categories']} pc ON pc.product_id = p.id
"""
)
conn.commit()
conn.close()
return redirect(f"{ADMIN_PATH}?scope={scope}")
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']} ORDER BY lower(name)")]
categories = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['categories']} ORDER BY lower(name)")]
products = [
dict(r)
for r in conn.execute(
f"""
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
, p.url
FROM {tables['products']} p
JOIN {tables['vendors']} v ON v.id = p.vendor_id
ORDER BY lower(v.name), lower(p.name)
"""
)
]
links = {
(r["product_id"], r["category_id"])
for r in conn.execute(f"SELECT product_id, category_id FROM {tables['product_categories']}")
}
conn.close()
return render_template_string(
ADMIN_HTML,
vendors=vendors,
categories=categories,
products=products,
links=links,
scope=scope,
)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/assets/mont-logo")
def mont_logo():
return send_from_directory(BASE_DIR, "mont_logo.png")
INDEX_HTML = """
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Z-карта вендоров</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #eef4ff;
--bg2: #dde9ff;
--panel: #ffffff;
--text: #15203b;
--muted: #526079;
--line: #c8d7f7;
--brand: #1f4ea3;
--brand-2: #3578ef;
--accent: #0f7b56;
--radius: 18px;
--shadow: 0 20px 55px rgba(16, 43, 95, .14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Manrope, sans-serif;
color: var(--text);
background:
radial-gradient(1200px 700px at -10% -10%, #c8dbff 0%, transparent 55%),
radial-gradient(900px 500px at 110% -20%, #d8f4ec 0%, transparent 50%),
linear-gradient(160deg, var(--bg) 0%, var(--bg2) 100%);
}
body.scope-ib {
background:
radial-gradient(1200px 700px at -10% -10%, #ffb3b3 0%, transparent 58%),
radial-gradient(900px 500px at 110% -20%, #ffbeb3 0%, transparent 52%),
linear-gradient(160deg, #ffd7d7 0%, #ffb8b8 100%);
}
body.scope-ib .hero {
background:
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
linear-gradient(125deg, #7a1f2a 0%, #b43444 55%, #d34d57 100%);
}
body.scope-ib .hero::after {
background-color: rgba(90, 15, 28, .45);
}
body.scope-ib .hero::before {
background:
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
rgba(74, 9, 24, .42);
}
body.scope-ib .mode-btn.active {
background: linear-gradient(140deg, #9a2331, #c03d4c);
box-shadow: 0 8px 18px rgba(158, 33, 51, .35);
}
.wrap {
width: min(1400px, calc(100% - 32px));
margin: 18px auto 28px;
}
.brand-strip {
margin-bottom: 12px;
display: flex;
justify-content: flex-start;
}
.hero {
background:
linear-gradient(160deg, rgba(255,255,255,.12), rgba(255,255,255,0) 45%),
linear-gradient(125deg, #173d83 0%, #2c63ca 55%, #3f83ff 100%);
color: #f5f8ff;
border-radius: calc(var(--radius) + 4px);
padding: 26px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0;
align-items: center;
position: relative;
z-index: 1;
}
.hero::after {
content: "";
position: absolute;
inset: auto -4% 0 -4%;
height: 62%;
background-color: rgba(17, 46, 102, .38);
clip-path: polygon(0 100%, 0 84%, 9% 52%, 17% 70%, 28% 42%, 39% 73%, 50% 32%, 61% 66%, 73% 38%, 84% 69%, 93% 47%, 100% 60%, 100% 100%);
}
.hero::before {
content: "";
position: absolute;
inset: auto -3% 0 -3%;
height: 50%;
background:
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
rgba(9, 31, 76, .32);
clip-path: polygon(0 100%, 0 90%, 8% 67%, 16% 82%, 25% 58%, 35% 85%, 47% 50%, 58% 80%, 69% 55%, 80% 79%, 91% 62%, 100% 74%, 100% 100%);
}
.hero h1 {
margin: 0 0 8px;
font-family: "Space Grotesk", sans-serif;
font-size: clamp(26px, 4.8vw, 48px);
letter-spacing: .3px;
line-height: 1.02;
}
.brand-logo {
width: clamp(170px, 24vw, 280px);
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.brand-logo img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .14));
}
.hero p {
margin: 0;
max-width: 860px;
color: rgba(245,248,255,.92);
font-size: 16px;
}
.board {
margin-top: 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.mode-switch {
display: inline-flex;
gap: 6px;
padding: 6px;
border-radius: 14px;
background: #ffffff;
border: 1px solid #d9e6ff;
box-shadow: 0 8px 24px rgba(26, 58, 118, .08);
margin-top: 12px;
}
.mode-btn {
border: 0;
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 800;
letter-spacing: .2px;
color: #2a4e8d;
background: transparent;
cursor: pointer;
transition: .18s ease;
}
.mode-btn:hover { background: #eef4ff; }
.mode-btn.active {
color: #fff;
background: linear-gradient(140deg, #1f4ea3, #3978e0);
box-shadow: 0 8px 18px rgba(38, 86, 176, .28);
}
.card {
background: var(--panel);
border: 1px solid #dfebff;
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(24, 56, 116, .08);
padding: 14px;
min-height: 320px;
}
.card h2 {
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: .9px;
color: #234782;
font-weight: 800;
}
.search {
width: 100%;
border: 1px solid #cfe0ff;
border-radius: 12px;
padding: 11px 12px;
font-size: 14px;
margin-bottom: 10px;
outline: none;
transition: .2s ease;
}
.search:focus {
border-color: #5b91f6;
box-shadow: 0 0 0 4px rgba(91,145,246,.14);
}
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 380px;
overflow: auto;
padding-right: 4px;
}
.chip {
border: 1px solid #ccdbf7;
border-radius: 999px;
padding: 8px 12px;
background: #f7faff;
color: #22427a;
font-size: 13px;
cursor: pointer;
user-select: none;
transition: .18s ease;
}
.chip:hover {
transform: translateY(-1px);
border-color: #98b9ef;
background: #f0f6ff;
}
.chip.active {
background: linear-gradient(140deg, #1f4ea3, #3978e0);
border-color: transparent;
color: #fff;
box-shadow: 0 6px 16px rgba(34,83,172,.25);
}
.chip.dim {
opacity: .45;
}
.footer-bar {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
button.action {
border: 0;
border-radius: 10px;
background: linear-gradient(140deg, #0d7e59, #0a6648);
color: #fff;
padding: 9px 14px;
font-weight: 700;
cursor: pointer;
}
.result {
margin-top: 16px;
background: #fff;
border-radius: var(--radius);
border: 1px solid #dfebff;
box-shadow: 0 10px 30px rgba(24, 56, 116, .07);
padding: 14px;
}
.result-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.result-head h3 {
margin: 0;
font-size: 17px;
color: #1f3f77;
}
.rows {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 12px;
}
.row-card {
border: 1px solid var(--line);
background: linear-gradient(180deg, #ffffff, #f5f9ff);
border-radius: 12px;
padding: 10px;
}
.row-card strong { color: #1a3e79; font-size: 14px; }
.tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
font-size: 12px;
border-radius: 999px;
background: #eaf2ff;
color: #234b89;
padding: 4px 8px;
border: 1px solid #d2e3ff;
}
a.tag {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
}
a.tag::after {
content: "";
font-size: 11px;
opacity: .85;
}
.credit {
position: fixed;
right: 16px;
bottom: 10px;
text-align: right;
line-height: 1.1;
z-index: 5;
}
.credit .name {
font-family: Caveat, cursive;
font-size: 28px;
color: #1c3f7c;
}
.credit a {
font-size: 13px;
color: #2f5fae;
text-decoration: none;
font-weight: 700;
}
@media (max-width: 980px) {
.brand-logo { max-width: 240px; }
.board { grid-template-columns: 1fr; }
.hero { padding: 20px; }
.credit { right: 8px; bottom: 6px; }
.credit .name { font-size: 10px; }
.credit a { font-size: 8px; }
}
</style>
</head>
<body>
<main class="wrap">
<section class="brand-strip">
<div class="brand-logo">
<img src="/assets/mont-logo" alt="MONT logo" />
</div>
</section>
<section class="hero">
<div class="hero-layout">
<div>
<h1>Вендоры в корзине МОНТ</h1>
<p>Актуальная матрица вендоров, продуктов и категорий. Выбирайте вендоров или категории, чтобы видеть релевантные продуктовые линейки в Инфраструктуре и ИБ.</p>
<div class="mode-switch">
<button id="modeInfra" class="mode-btn active" type="button">Инфраструктура</button>
<button id="modeIb" class="mode-btn" type="button">ИБ</button>
</div>
</div>
</div>
</section>
<section class="board">
<article class="card">
<h2>Вендоры</h2>
<input id="vendorSearch" class="search" placeholder="Поиск вендора..." />
<div id="vendorList" class="chip-grid"></div>
</article>
<article class="card">
<h2>Категории</h2>
<input id="categorySearch" class="search" placeholder="Поиск категории..." />
<div id="categoryList" class="chip-grid"></div>
</article>
</section>
<div class="footer-bar">
<div id="stats">Загрузка...</div>
<button class="action" id="clearBtn">Сбросить фильтры</button>
</div>
<section class="result">
<div class="result-head">
<h3>Вендоры и продукты (после фильтрации)</h3>
</div>
<div id="resultRows" class="rows"></div>
</section>
<div class="credit">
<div class="name">Made by Galyaviev</div>
<a href="mailto:RGalyaviev@mont.com">RGalyaviev@mont.com</a>
</div>
</main>
<script>
const state = {
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
scope: "infra",
selectedVendors: new Set(),
selectedCategories: new Set(),
vendorSearch: "",
categorySearch: "",
};
const el = {
vendorList: document.getElementById("vendorList"),
categoryList: document.getElementById("categoryList"),
vendorSearch: document.getElementById("vendorSearch"),
categorySearch: document.getElementById("categorySearch"),
resultRows: document.getElementById("resultRows"),
stats: document.getElementById("stats"),
clearBtn: document.getElementById("clearBtn"),
modeInfra: document.getElementById("modeInfra"),
modeIb: document.getElementById("modeIb"),
};
function normalize(s) {
return s.toLowerCase().replace(/ё/g, "е");
}
function getMaps() {
const productsByVendor = new Map();
const categoriesByProduct = new Map();
const productsByCategory = new Map();
for (const v of state.data.vendors) productsByVendor.set(v.id, new Set());
for (const p of state.data.products) categoriesByProduct.set(p.id, new Set());
for (const c of state.data.categories) productsByCategory.set(c.id, new Set());
for (const p of state.data.products) {
if (!productsByVendor.has(p.vendor_id)) productsByVendor.set(p.vendor_id, new Set());
productsByVendor.get(p.vendor_id).add(p.id);
}
for (const l of state.data.product_links) {
if (!categoriesByProduct.has(l.product_id)) categoriesByProduct.set(l.product_id, new Set());
if (!productsByCategory.has(l.category_id)) productsByCategory.set(l.category_id, new Set());
categoriesByProduct.get(l.product_id).add(l.category_id);
productsByCategory.get(l.category_id).add(l.product_id);
}
return { productsByVendor, categoriesByProduct, productsByCategory };
}
function visibleSets() {
const { productsByVendor, categoriesByProduct, productsByCategory } = getMaps();
const allowedCategories = new Set(state.data.categories.map(c => c.id));
const allowedVendors = new Set(state.data.vendors.map(v => v.id));
const allowedProducts = new Set(state.data.products.map(p => p.id));
if (state.selectedVendors.size > 0) {
const fromVendorProducts = new Set();
for (const vId of state.selectedVendors) {
for (const pId of (productsByVendor.get(vId) || [])) fromVendorProducts.add(pId);
}
for (const pId of Array.from(allowedProducts)) {
if (!fromVendorProducts.has(pId)) allowedProducts.delete(pId);
}
}
if (state.selectedCategories.size > 0) {
const fromCategories = new Set();
for (const cId of state.selectedCategories) {
for (const pId of (productsByCategory.get(cId) || [])) fromCategories.add(pId);
}
for (const pId of Array.from(allowedProducts)) {
if (!fromCategories.has(pId)) allowedProducts.delete(pId);
}
}
for (const cId of Array.from(allowedCategories)) {
const products = productsByCategory.get(cId) || new Set();
if (![...products].some(pId => allowedProducts.has(pId))) {
allowedCategories.delete(cId);
}
}
for (const vId of Array.from(allowedVendors)) {
const products = productsByVendor.get(vId) || new Set();
if (![...products].some(pId => allowedProducts.has(pId))) {
allowedVendors.delete(vId);
}
}
return { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct };
}
function renderChips() {
const { allowedCategories, allowedVendors } = visibleSets();
const vendorQ = normalize(state.vendorSearch);
const categoryQ = normalize(state.categorySearch);
el.vendorList.innerHTML = "";
for (const vendor of state.data.vendors) {
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
const node = document.createElement("button");
node.className = "chip";
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
node.textContent = vendor.name;
node.addEventListener("click", () => {
if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id);
else state.selectedVendors.add(vendor.id);
render();
});
el.vendorList.appendChild(node);
}
el.categoryList.innerHTML = "";
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
for (const category of state.data.categories) {
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
const node = document.createElement("button");
node.className = "chip";
if (state.selectedCategories.has(category.id)) node.classList.add("active");
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
node.textContent = category.name;
node.addEventListener("click", () => {
if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id);
else state.selectedCategories.add(category.id);
render();
});
el.categoryList.appendChild(node);
}
el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`;
}
function renderResults() {
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
const productsById = new Map(state.data.products.map(p => [p.id, p]));
const rows = [];
for (const vendor of state.data.vendors) {
if (!allowedVendors.has(vendor.id)) continue;
const productIds = [...(productsByVendor.get(vendor.id) || [])]
.filter(pId => allowedProducts.has(pId))
.filter(pId => {
if (state.selectedCategories.size === 0) return true;
const cats = categoriesByProduct.get(pId) || new Set();
return [...state.selectedCategories].some(cId => cats.has(cId));
});
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
if (products.length === 0) continue;
rows.push({ vendor: vendor.name, products });
}
el.resultRows.innerHTML = "";
if (rows.length === 0) {
el.resultRows.innerHTML = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
return;
}
for (const row of rows) {
const card = document.createElement("article");
card.className = "row-card";
const title = document.createElement("strong");
title.textContent = row.vendor;
card.appendChild(title);
const tags = document.createElement("div");
tags.className = "tags";
for (const product of row.products) {
const hasUrl = product.url && String(product.url).trim().length > 0;
const tag = document.createElement(hasUrl ? "a" : "span");
tag.className = "tag";
tag.textContent = product.name;
if (hasUrl) {
tag.href = product.url;
tag.target = "_blank";
tag.rel = "noopener noreferrer";
}
tags.appendChild(tag);
}
card.appendChild(tags);
el.resultRows.appendChild(card);
}
}
function render() {
el.modeInfra.classList.toggle("active", state.scope === "infra");
el.modeIb.classList.toggle("active", state.scope === "ib");
document.body.classList.toggle("scope-ib", state.scope === "ib");
renderChips();
renderResults();
}
async function loadScopeData(scope) {
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
state.data = await res.json();
}
async function init() {
await loadScopeData(state.scope);
render();
}
async function switchScope(scope) {
if (scope === state.scope) return;
state.scope = scope;
state.selectedVendors.clear();
state.selectedCategories.clear();
state.vendorSearch = "";
state.categorySearch = "";
el.vendorSearch.value = "";
el.categorySearch.value = "";
await loadScopeData(scope);
render();
}
el.vendorSearch.addEventListener("input", e => {
state.vendorSearch = e.target.value || "";
render();
});
el.categorySearch.addEventListener("input", e => {
state.categorySearch = e.target.value || "";
render();
});
el.clearBtn.addEventListener("click", () => {
state.selectedVendors.clear();
state.selectedCategories.clear();
state.vendorSearch = "";
state.categorySearch = "";
el.vendorSearch.value = "";
el.categorySearch.value = "";
render();
});
el.modeInfra.addEventListener("click", () => {
switchScope("infra");
});
el.modeIb.addEventListener("click", () => {
switchScope("ib");
});
init();
</script>
</body>
</html>
"""
LOGIN_HTML = """
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(135deg, #dbe8ff, #eff5ff);
font-family: Manrope, sans-serif;
}
form {
width: min(430px, calc(100% - 24px));
background: #fff;
border-radius: 16px;
border: 1px solid #d5e3fb;
padding: 22px;
box-shadow: 0 16px 36px rgba(22,61,126,.13);
}
h1 { margin: 0 0 14px; color: #1f4ea3; font-size: 22px; }
input {
width: 100%;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid #c8daf8;
border-radius: 10px;
font-size: 14px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
border: 0;
border-radius: 10px;
color: #fff;
font-weight: 700;
background: linear-gradient(130deg, #1f4ea3, #3775de);
cursor: pointer;
}
.error { color: #a02020; margin: 0 0 10px; font-size: 14px; }
</style>
</head>
<body>
<form method="post">
<h1>Редактор матрицы</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<input type="hidden" name="action" value="login" />
<input name="username" placeholder="Логин" autocomplete="username" required />
<input type="password" name="password" placeholder="Пароль" autocomplete="current-password" required />
<button type="submit">Войти</button>
</form>
</body>
</html>
"""
ADMIN_HTML = """
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Matrix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root { --b:#1f4ea3; --line:#cfe0ff; }
* { box-sizing: border-box; }
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
body.ib { background:#fff1f1; }
.wrap { width:min(1600px, calc(100% - 24px)); margin:12px auto 24px; }
.top {
background: linear-gradient(130deg, #1f4ea3, #3977df);
border-radius: 14px;
color:#fff;
padding:14px 16px;
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
}
body.ib .top { background: linear-gradient(130deg, #9b2f3a, #c24a56); }
.scope-switch { display:flex; gap:8px; }
.scope-chip {
display:inline-block;
padding:7px 11px;
border-radius:9px;
text-decoration:none;
font-weight:700;
background:#e7efff;
color:#1f3f77;
border:1px solid #ccdcff;
}
.scope-chip.active { background:#fff; color:#112847; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:10px; margin:12px 0; }
.box {
background:#fff;
border:1px solid #d4e3ff;
border-radius:12px;
padding:12px;
}
form.inline { display:flex; gap:8px; }
input[type="text"] { flex:1; padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px; }
button { border:0; border-radius:9px; padding:9px 11px; cursor:pointer; font-weight:700; }
.pri { background:#1f4ea3; color:#fff; }
.warn { background:#e8eefc; color:#223963; }
.danger { background:#ffefef; color:#8e1d1d; }
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; }
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; overflow:auto; }
table { border-collapse: collapse; min-width: 1200px; }
th, td { border:1px solid var(--line); padding:6px; font-size:12px; text-align:center; }
th { position: sticky; top: 0; background:#eaf2ff; z-index: 2; }
th:first-child, td:first-child { position: sticky; left:0; background:#eef5ff; z-index: 1; text-align:left; min-width: 280px; }
td input { transform: scale(1.05); }
</style>
</head>
<body class="{% if scope == 'ib' %}ib{% endif %}">
<main class="wrap">
<section class="top">
<div>
<strong>Админ-панель матрицы</strong>
<div class="scope-switch" style="margin-top:8px;">
<a class="scope-chip {% if scope == 'infra' %}active{% endif %}" href="{{ request.path }}?scope=infra">Инфраструктура</a>
<a class="scope-chip {% if scope == 'ib' %}active{% endif %}" href="{{ request.path }}?scope=ib">ИБ</a>
</div>
</div>
<div style="display:flex; gap:8px;">
<a href="/" style="text-decoration:none;"><button class="warn" type="button">На сайт</button></a>
<form method="post" style="margin:0;">
<input type="hidden" name="scope" value="{{ scope }}" />
<input type="hidden" name="action" value="logout" />
<button class="danger" type="submit">Выйти</button>
</form>
</div>
</section>
<section class="grid">
<div class="box">
<h3>Добавить вендора</h3>
<form method="post" class="inline">
<input type="hidden" name="scope" value="{{ scope }}" />
<input type="hidden" name="action" value="add_vendor" />
<input type="text" name="name" placeholder="Название вендора" required />
<button class="pri" type="submit">Добавить</button>
</form>
</div>
<div class="box">
<h3>Добавить категорию</h3>
<form method="post" class="inline">
<input type="hidden" name="scope" value="{{ scope }}" />
<input type="hidden" name="action" value="add_category" />
<input type="text" name="name" placeholder="Название категории" required />
<button class="pri" type="submit">Добавить</button>
</form>
</div>
<div class="box">
<h3>Добавить продукт</h3>
<form method="post" class="inline">
<input type="hidden" name="scope" value="{{ scope }}" />
<input type="hidden" name="action" value="add_product" />
<select name="vendor_id" required style="padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px;">
{% for v in vendors %}
<option value="{{ v.id }}">{{ v.name }}</option>
{% endfor %}
</select>
<input type="text" name="name" placeholder="Название продукта" required />
<input type="text" name="url" placeholder="URL продукта (необязательно)" />
<button class="pri" type="submit">Добавить</button>
</form>
</div>
</section>
<section class="lists">
<div class="box">
<h3>Удалить вендора</h3>
{% for v in vendors %}
<form class="list-item" method="post">
<input type="hidden" name="scope" value="{{ scope }}" />
<span>{{ v.name }}</span>
<input type="hidden" name="action" value="delete_vendor" />
<input type="hidden" name="vendor_id" value="{{ v.id }}" />
<button class="danger" type="submit">Удалить</button>
</form>
{% endfor %}
</div>
<div class="box">
<h3>Удалить категорию</h3>
{% for c in categories %}
<form class="list-item" method="post">
<input type="hidden" name="scope" value="{{ scope }}" />
<span>{{ c.name }}</span>
<input type="hidden" name="action" value="delete_category" />
<input type="hidden" name="category_id" value="{{ c.id }}" />
<button class="danger" type="submit">Удалить</button>
</form>
{% endfor %}
</div>
<div class="box">
<h3>Удалить продукт</h3>
{% for p in products %}
<form class="list-item" method="post">
<input type="hidden" name="scope" value="{{ scope }}" />
<span>
{{ p.vendor_name }} :: {{ p.name }}
{% if p.url %}<a href="{{ p.url }}" target="_blank" rel="noopener noreferrer" style="margin-left:6px; font-size:11px;">ссылка</a>{% endif %}
</span>
<input type="hidden" name="action" value="delete_product" />
<input type="hidden" name="product_id" value="{{ p.id }}" />
<button class="danger" type="submit">Удалить</button>
</form>
{% endfor %}
</div>
</section>
<section class="matrix-wrap">
<form method="post">
<input type="hidden" name="scope" value="{{ scope }}" />
<input type="hidden" name="action" value="save_matrix" />
<button class="pri" type="submit" style="margin-bottom:10px;">Сохранить матрицу продуктов</button>
<table>
<tr>
<th>Вендор / Продукт</th>
{% for c in categories %}
<th>{{ c.name }}</th>
{% endfor %}
</tr>
{% for p in products %}
<tr>
<td><strong>{{ p.vendor_name }}</strong><br/>{{ p.name }}</td>
{% for c in categories %}
<td>
<input
type="checkbox"
name="pc_{{ p.id }}_{{ c.id }}"
{% if (p.id, c.id) in links %}checked{% endif %}
/>
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
</form>
</section>
</main>
</body>
</html>
"""
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000, debug=True)
else:
init_db()