first commit

This commit is contained in:
Илья Братских 2026-06-24 15:45:51 +03:00 committed by KBAHTNET
commit 31f0276cb3
20 changed files with 1236 additions and 0 deletions

1
assets/ceph.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="80" r="40" fill="white" fill-opacity=".16"/><circle cx="80" cy="80" r="16" fill="white"/><circle cx="45" cy="65" r="15" fill="white"/><circle cx="115" cy="65" r="15" fill="white"/><circle cx="62" cy="112" r="15" fill="white"/><circle cx="98" cy="112" r="15" fill="white"/><path d="M57 71l16 7M103 71l-16 7M70 104l7-13M90 91l7 13" stroke="#BE123C" stroke-width="6" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FB7185"/><stop offset="1" stop-color="#BE123C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 723 B

1
assets/config.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="80" r="34" fill="white"/><path d="M80 44v18M80 98v18M44 80h18M98 80h18M55 55l13 13M92 92l13 13M105 55L92 68M68 92l-13 13" stroke="#4338CA" stroke-width="8" stroke-linecap="round"/><circle cx="80" cy="80" r="12" fill="#4338CA"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#818CF8"/><stop offset="1" stop-color="#4338CA"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 562 B

1
assets/database.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><ellipse cx="80" cy="50" rx="42" ry="18" fill="white"/><path d="M38 50v60c0 10 19 18 42 18s42-8 42-18V50" fill="white" fill-opacity=".86"/><path d="M38 78c0 10 19 18 42 18s42-8 42-18M38 104c0 10 19 18 42 18s42-8 42-18" stroke="#0F766E" stroke-width="7"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#2DD4BF"/><stop offset="1" stop-color="#0F766E"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 569 B

1
assets/gitea.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M49 60h53c16 0 28 12 28 28s-12 28-28 28H73c-22 0-40-18-40-40 0-9 7-16 16-16z" fill="white"/><circle cx="67" cy="86" r="7" fill="#4D7C0F"/><circle cx="101" cy="86" r="7" fill="#4D7C0F"/><path d="M75 102c7 5 16 5 24 0" stroke="#4D7C0F" stroke-width="6" stroke-linecap="round"/><path d="M103 57l13-13M113 45h14v14" stroke="#FEF3C7" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#A3E635"/><stop offset="1" stop-color="#65A30D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 718 B

1
assets/grafana.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M82 124c-27 0-49-18-49-43 0-22 18-40 41-40 18 0 33 12 33 29 0 14-11 25-25 25-11 0-20-8-20-18 0-8 7-15 16-15 6 0 11 4 11 10" stroke="white" stroke-width="10" stroke-linecap="round"/><circle cx="116" cy="43" r="13" fill="#FDE68A"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#F97316"/><stop offset="1" stop-color="#7C2D12"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 553 B

1
assets/kubernetes.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M80 30l45 26v52l-45 26-45-26V56l45-26z" fill="white" fill-opacity=".18" stroke="white" stroke-width="8" stroke-linejoin="round"/><circle cx="80" cy="82" r="22" fill="white"/><path d="M80 48v68M46 82h68M56 58l48 48M104 58l-48 48" stroke="#2563EB" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#60A5FA"/><stop offset="1" stop-color="#1D4ED8"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 611 B

1
assets/ldap.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="80" cy="56" r="22" fill="white"/><path d="M42 122c6-28 20-42 38-42s32 14 38 42" fill="white"/><circle cx="43" cy="80" r="13" fill="white" fill-opacity=".82"/><circle cx="117" cy="80" r="13" fill="white" fill-opacity=".82"/><path d="M56 86h48" stroke="#047857" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#6EE7B7"/><stop offset="1" stop-color="#047857"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 628 B

1
assets/messages.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M36 48h88c8 0 14 6 14 14v38c0 8-6 14-14 14H78l-28 18v-18H36c-8 0-14-6-14-14V62c0-8 6-14 14-14z" fill="white"/><path d="M50 72h60M50 92h42" stroke="#0891B2" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#67E8F9"/><stop offset="1" stop-color="#0891B2"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 521 B

1
assets/opensearch.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="70" cy="70" r="30" stroke="white" stroke-width="12"/><path d="M93 93l27 27" stroke="white" stroke-width="13" stroke-linecap="round"/><path d="M49 69h42M58 53h24M58 85h24" stroke="#0369A1" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#38BDF8"/><stop offset="1" stop-color="#0369A1"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 556 B

1
assets/prometheus.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M80 34c22 24 34 44 34 62 0 20-15 34-34 34s-34-14-34-34c0-18 12-38 34-62z" fill="white"/><path d="M66 82c12 8 16 8 28 0M65 103h30M80 54v32" stroke="#DC2626" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FCA5A5"/><stop offset="1" stop-color="#DC2626"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 521 B

1
assets/rabbitmq.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M42 112V48c0-8 6-14 14-14s14 6 14 14v28h20V48c0-8 6-14 14-14s14 6 14 14v64H42z" fill="white"/><rect x="64" y="88" width="18" height="18" rx="4" fill="#EA580C"/><rect x="94" y="88" width="18" height="18" rx="4" fill="#EA580C"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FDBA74"/><stop offset="1" stop-color="#EA580C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 550 B

1
assets/redmine.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="38" y="38" width="84" height="84" rx="18" fill="white"/><path d="M58 66h44M58 86h30M58 106h44" stroke="#B91C1C" stroke-width="8" stroke-linecap="round"/><circle cx="104" cy="86" r="10" fill="#FCA5A5"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#F87171"/><stop offset="1" stop-color="#B91C1C"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 525 B

1
assets/rules.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="40" y="34" width="80" height="92" rx="16" fill="white"/><path d="M60 62h42M60 82h42M60 102h24" stroke="#6D28D9" stroke-width="8" stroke-linecap="round"/><path d="M47 62l6 6 12-15M47 102l6 6 12-15" stroke="#22C55E" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#C4B5FD"/><stop offset="1" stop-color="#6D28D9"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 603 B

1
assets/search.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><circle cx="70" cy="70" r="30" stroke="white" stroke-width="12"/><path d="M94 94l28 28" stroke="white" stroke-width="13" stroke-linecap="round"/><path d="M60 70h20M70 60v20" stroke="#6D28D9" stroke-width="7" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#C4B5FD"/><stop offset="1" stop-color="#6D28D9"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 547 B

1
assets/server.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><rect x="42" y="32" width="76" height="96" rx="14" fill="white"/><path d="M56 55h48M56 80h48M56 105h48" stroke="#334155" stroke-width="8" stroke-linecap="round"/><circle cx="103" cy="55" r="5" fill="#22C55E"/><circle cx="103" cy="80" r="5" fill="#FACC15"/><circle cx="103" cy="105" r="5" fill="#EF4444"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#94A3B8"/><stop offset="1" stop-color="#334155"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 619 B

1
assets/xmpp.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M46 44c10 38 21 61 34 82 13-21 24-44 34-82" stroke="white" stroke-width="12" stroke-linecap="round"/><path d="M46 44c24 29 44 45 68 60M114 44c-24 29-44 45-68 60" stroke="white" stroke-opacity=".62" stroke-width="8" stroke-linecap="round"/><circle cx="80" cy="84" r="10" fill="#15803D"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#86EFAC"/><stop offset="1" stop-color="#15803D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 610 B

1
assets/xpath.svg Normal file
View File

@ -0,0 +1 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="44" fill="url(#g)"/><path d="M62 50L34 80l28 30M98 50l28 30-28 30" stroke="white" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><path d="M87 42L69 118" stroke="#FDE68A" stroke-width="10" stroke-linecap="round"/><defs><linearGradient id="g" x1="18" y1="14" x2="140" y2="150"><stop stop-color="#FBBF24"/><stop offset="1" stop-color="#B45309"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 526 B

127
index.html Normal file
View File

@ -0,0 +1,127 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Навигационная панель экосистемы с разделами, подразделами, поиском и быстрыми переходами."
/>
<title>Панель экосистемы</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=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<section class="hero" aria-labelledby="page-title">
<div class="hero-content">
<p class="eyebrow">Infrastructure map</p>
<h1 id="page-title">Панель экосистемы</h1>
<p class="lead">
Единая точка входа в пользовательские, административные,
мониторинговые и инфраструктурные интерфейсы.
</p>
</div>
<div class="control-panel" aria-label="Настройки панели">
<label class="search-field">
<span>Поиск</span>
<input
type="search"
placeholder="Grafana, S3, журнал, сервер..."
autocomplete="off"
data-search-input
/>
</label>
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<div class="control-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
</div>
<div class="control-group accent-group">
<span>Акцент</span>
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
</div>
</div>
</section>
<section class="overview" aria-label="Сводка">
<div>
<span data-total-sections>0</span>
<p>раздела</p>
</div>
<div>
<span data-total-groups>0</span>
<p>подразделов</p>
</div>
<div>
<span data-total-items>0</span>
<p>элементов</p>
</div>
<div>
<span data-visible-items>0</span>
<p>найдено</p>
</div>
</section>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<template id="empty-template">
<div class="empty-state">
<img src="assets/search.svg" alt="" />
<h2>Ничего не найдено</h2>
<p>Попробуй изменить запрос или искать по названию системы, тегу или роли.</p>
</div>
</template>
</main>
<script src="script.js"></script>
</body>
</html>

405
script.js Normal file
View File

@ -0,0 +1,405 @@
const STORAGE_KEY = "ecosystem-panel-preferences";
// Меняй навигацию здесь: разделы -> подразделы -> элементы.
// url можно заменить на реальные адреса, tags помогают поиску.
const ecosystemSections = [
{
id: "user",
number: "1",
title: "Интерфейс пользователя",
description: "Рабочие инструменты пользователя для фильтрации и обработки сообщений.",
groups: [
{
id: "message-filtering",
number: "1.1",
title: "Управление фильтрацией сообщений",
description: "Правила, задания, события и статистика фильтрации.",
items: [
createItem("1.1.1", "Редактирование правил", "rules", ["правила", "фильтрация", "редактор"]),
createItem("1.1.2", "Управление заданиями", "redmine", ["redmine", "задачи", "задания"]),
createItem("1.1.3", "Применение правил", "rules", ["правила", "применение", "фильтрация"]),
createItem("1.1.4", "Просмотр статистики", "grafana", ["grafana", "статистика", "дашборд"]),
createItem("1.1.5", "Управление событиями фильтрации", "grafana", ["grafana", "события", "алерты"]),
createItem("1.1.6", "Просмотр журнала событий фильтрации", "grafana", ["grafana", "журнал", "события"]),
],
},
{
id: "message-processing",
number: "1.2",
title: "Обработка сообщений",
description: "Просмотр, оформление и доступ к хранилищам сообщений.",
items: [
createItem("1.2.1", "Просмотр и оформление", "messages", ["сообщения", "просмотр", "оформление"]),
createItem("1.2.2", "Прямой доступ", "ceph", ["ceph", "доступ", "объекты"]),
createItem("1.2.3", "S3 Хранилище", "ceph", ["ceph", "s3", "хранилище"]),
],
},
],
},
{
id: "admin",
number: "2",
title: "Интерфейс администратора",
description: "Администрирование сервисов, кластеров, мониторинга и аппаратной части.",
groups: [
{
id: "service-monitoring",
number: "2.1",
title: "Управление и мониторинг сервисов",
description: "Кластер, модули, события мониторинга и журналы ошибок.",
items: [
createItem("2.1.1", "Управление кластером", "kubernetes", ["kubernetes", "кластер", "k8s"]),
createItem("2.1.2", "Мониторинг состояния модулей", "grafana", ["grafana", "модули", "мониторинг"]),
createItem("2.1.3", "Управление событиями мониторинга", "grafana", ["grafana", "события", "алерты"]),
createItem("2.1.4", "Просмотр журнала событий мониторинга", "grafana", ["grafana", "журнал", "мониторинг"]),
createItem("2.1.5", "Просмотр журналов ошибок", "opensearch", ["opensearch", "ошибки", "логи"]),
],
},
{
id: "service-configuration",
number: "2.2",
title: "Настройка сервисов",
description: "Пользователи, очереди, xPath, модули и конфигурации.",
items: [
createItem("2.2.1", "Управление пользователями и очередями", "rabbitmq", ["пользователи", "очереди", "rabbitmq"]),
createItem("2.2.2", "Отладка xPath-запросов", "xpath", ["xpath", "отладка", "запросы"]),
createItem("2.2.3", "Конфигурирование модулей", "config", ["конфигурация", "модули", "настройки"]),
createItem("2.2.4", "Просмотр журнала конфигурирования", "gitea", ["gitea", "журнал", "конфигурация"]),
createItem("2.2.5", "Прямой доступ к конфигурации", "gitea", ["gitea", "конфигурация", "git"]),
],
},
{
id: "hardware",
number: "2.3",
title: "Управление и мониторинг аппаратной части",
description: "Сенсоры, ресурсы и прямые панели серверов.",
items: [
createItem("2.3.1", "Мониторинг показаний сенсоров", "grafana", ["grafana", "сенсоры", "железо"]),
createItem("2.3.2", "Мониторинг потребления ресурсов", "grafana", ["grafana", "ресурсы", "метрики"]),
createItem("2.3.3", "СЕРВЕР01", "ipmi", ["ipmi", "сервер01", "железо"]),
createItem("2.3.4", "СЕРВЕР02", "ipmi", ["ipmi", "сервер02", "железо"]),
createItem("2.3.5", "СЕРВЕР03", "ipmi", ["ipmi", "сервер03", "железо"]),
createItem("2.3.6", "СЕРВЕР04", "ipmi", ["ipmi", "сервер04", "железо"]),
createItem("2.3.7", "СЕРВЕР05", "ipmi", ["ipmi", "сервер05", "железо"]),
],
},
{
id: "service-panels",
number: "2.4",
title: "Настройка отдельных сервисов",
description: "Панели управления инфраструктурными сервисами.",
items: [
createItem("2.4.1", "Панель управления LDAP Account Manager", "ldap", ["ldap", "accounts", "пользователи"]),
createItem("2.4.2", "Панель управления кластера RabbitMQ", "rabbitmq", ["rabbitmq", "кластер", "очереди"]),
createItem("2.4.3", "Панель управления кластера Ceph", "ceph", ["ceph", "кластер", "storage"]),
createItem("2.4.4", "Панель управления Prometheus", "prometheus", ["prometheus", "метрики", "мониторинг"]),
createItem("2.4.5", "Панель управления XMPP-сервера", "xmpp", ["xmpp", "чат", "сервер"]),
createItem("2.4.6", "Панель управления PostgreSQL", "postgresql", ["postgresql", "база", "db"]),
createItem("2.4.7", "Подключение к БД", "cloudbeaver", ["cloudbeaver", "database", "sql"]),
],
},
{
id: "packages",
number: "2.5",
title: "Образы и пакеты",
description: "Реестры образов и deb-пакетов.",
items: [
createItem("2.5.1", "Реестр docker-образов", "gitea", ["gitea", "docker", "registry"]),
createItem("2.5.2", "Репозиторий deb-пакетов", "gitea", ["gitea", "deb", "packages"]),
],
},
],
},
];
const serviceMeta = {
ceph: { label: "Ceph", icon: "assets/ceph.svg" },
cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" },
config: { label: "Config", icon: "assets/config.svg" },
gitea: { label: "Gitea", icon: "assets/gitea.svg" },
grafana: { label: "Grafana", icon: "assets/grafana.svg" },
ipmi: { label: "IPMI", icon: "assets/server.svg" },
kubernetes: { label: "Kubernetes", icon: "assets/kubernetes.svg" },
ldap: { label: "LDAP", icon: "assets/ldap.svg" },
messages: { label: "Messages", icon: "assets/messages.svg" },
opensearch: { label: "OpenSearch", icon: "assets/opensearch.svg" },
postgresql: { label: "PostgreSQL", icon: "assets/database.svg" },
prometheus: { label: "Prometheus", icon: "assets/prometheus.svg" },
rabbitmq: { label: "RabbitMQ", icon: "assets/rabbitmq.svg" },
redmine: { label: "Redmine", icon: "assets/redmine.svg" },
rules: { label: "Rules", icon: "assets/rules.svg" },
xpath: { label: "xPath", icon: "assets/xpath.svg" },
xmpp: { label: "XMPP", icon: "assets/xmpp.svg" },
};
const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards",
accent: "violet",
};
const preferences = { ...defaultPreferences, ...readPreferences() };
const catalog = document.querySelector("[data-catalog-root]");
const searchInput = document.querySelector("[data-search-input]");
const themeButtons = document.querySelectorAll("[data-theme]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const emptyTemplate = document.querySelector("#empty-template");
const totals = calculateTotals(ecosystemSections);
document.querySelector("[data-total-sections]").textContent = totals.sections;
document.querySelector("[data-total-groups]").textContent = totals.groups;
document.querySelector("[data-total-items]").textContent = totals.items;
applyPreferences(preferences);
renderCatalog();
searchInput.addEventListener("input", renderCatalog);
themeButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.theme = button.dataset.theme;
applyPreferences(preferences);
savePreferences(preferences);
});
});
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
});
});
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
});
});
function createItem(number, title, type, tags = [], url = "#") {
return { number, title, type, tags, url };
}
function renderCatalog() {
const query = normalize(searchInput.value);
const fragment = document.createDocumentFragment();
let visibleItems = 0;
ecosystemSections.forEach((section) => {
const sectionElement = createSectionElement(section);
let visibleGroups = 0;
let sectionVisibleItems = 0;
section.groups.forEach((group) => {
const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query));
if (!filteredItems.length) {
return;
}
visibleGroups += 1;
sectionVisibleItems += filteredItems.length;
sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems));
});
if (visibleGroups) {
sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems;
fragment.append(sectionElement);
visibleItems += sectionVisibleItems;
}
});
catalog.replaceChildren();
if (visibleItems) {
catalog.append(fragment);
attachCardInteractions();
} else {
catalog.append(emptyTemplate.content.cloneNode(true));
}
document.querySelector("[data-visible-items]").textContent = visibleItems;
}
function createSectionElement(section) {
const sectionElement = document.createElement("article");
sectionElement.className = "catalog-section";
sectionElement.innerHTML = `
<header class="section-head">
<div>
<p class="eyebrow">${section.number} раздел</p>
<h2>${section.title}</h2>
<p>${section.description}</p>
</div>
<span class="section-count"><span data-section-count>0</span> элементов</span>
</header>
<div class="section-groups"></div>
`;
return sectionElement;
}
function createGroupElement(group, items) {
const groupElement = document.createElement("section");
groupElement.className = "catalog-group";
const cards = items.map(createCardElement);
groupElement.innerHTML = `
<header class="group-head">
<div>
<p class="group-number">${group.number}</p>
<h3>${group.title}</h3>
<p>${group.description}</p>
</div>
<span>${items.length}</span>
</header>
`;
const grid = document.createElement("div");
grid.className = "items-grid";
grid.append(...cards);
groupElement.append(grid);
return groupElement;
}
function createCardElement(item) {
const meta = serviceMeta[item.type] || serviceMeta.config;
const link = document.createElement("a");
link.className = "nav-card";
link.href = item.url;
link.dataset.type = item.type;
if (item.url === "#") {
link.setAttribute("aria-label", `${item.title}: URL еще не настроен`);
link.addEventListener("click", (event) => event.preventDefault());
} else {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
link.innerHTML = `
<span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" />
<div class="card-content">
<p class="item-number">${item.number}</p>
<h4>${item.title}</h4>
<p class="item-type">${meta.label}</p>
<div class="tags">${item.tags.map((tag) => `<span>${tag}</span>`).join("")}</div>
</div>
<span class="arrow" aria-hidden="true">→</span>
`;
return link;
}
function matchesQuery(item, group, section, query) {
if (!query) {
return true;
}
const meta = serviceMeta[item.type] || serviceMeta.config;
const haystack = normalize(
[
item.number,
item.title,
item.type,
meta.label,
item.tags.join(" "),
group.title,
section.title,
].join(" "),
);
return haystack.includes(query);
}
function attachCardInteractions() {
document.querySelectorAll(".nav-card").forEach((card) => {
let leaveTimer;
card.addEventListener("pointerenter", () => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
});
card.addEventListener("pointermove", (event) => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
const rect = card.getBoundingClientRect();
const xPercent = clamp(((event.clientX - rect.left) / rect.width) * 100, 8, 92);
const yPercent = clamp(((event.clientY - rect.top) / rect.height) * 100, 8, 92);
const rotateY = ((xPercent - 50) / 50) * 2.4;
const rotateX = ((50 - yPercent) / 50) * 2.4;
card.style.setProperty("--card-x", `${xPercent}%`);
card.style.setProperty("--card-y", `${yPercent}%`);
card.style.setProperty("--tilt-x", `${rotateX}deg`);
card.style.setProperty("--tilt-y", `${rotateY}deg`);
});
card.addEventListener("pointerleave", () => {
leaveTimer = window.setTimeout(() => {
card.classList.remove("is-hovered");
card.style.setProperty("--card-x", "50%");
card.style.setProperty("--card-y", "50%");
card.style.setProperty("--tilt-x", "0deg");
card.style.setProperty("--tilt-y", "0deg");
}, 80);
});
});
}
function applyPreferences(nextPreferences) {
document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent;
catalog.classList.toggle("cards-view", nextPreferences.view === "cards");
catalog.classList.toggle("list-view", nextPreferences.view === "list");
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
}
function setActiveButton(buttons, key, value) {
buttons.forEach((button) => {
const isActive = button.dataset[key] === value;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
}
function calculateTotals(sections) {
return sections.reduce(
(total, section) => {
total.sections += 1;
total.groups += section.groups.length;
total.items += section.groups.reduce((sum, group) => sum + group.items.length, 0);
return total;
},
{ sections: 0, groups: 0, items: 0 },
);
}
function normalize(value) {
return value.toLowerCase().replaceAll("ё", "е").trim();
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function readPreferences() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch {
return {};
}
}
function savePreferences(nextPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
}

687
styles.css Normal file
View File

@ -0,0 +1,687 @@
:root {
color-scheme: dark;
--bg: #080a12;
--bg-soft: #111827;
--panel: rgba(15, 23, 42, 0.74);
--panel-strong: rgba(15, 23, 42, 0.92);
--surface: rgba(255, 255, 255, 0.055);
--text: #f8fafc;
--muted: #aeb8c8;
--border: rgba(255, 255, 255, 0.12);
--shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
--accent: #8b5cf6;
--accent-2: #22d3ee;
--accent-rgb: 139, 92, 246;
--card-x: 50%;
--card-y: 50%;
}
html[data-theme="light"] {
color-scheme: light;
--bg: #f5f7fb;
--bg-soft: #ffffff;
--panel: rgba(255, 255, 255, 0.78);
--panel-strong: rgba(255, 255, 255, 0.96);
--surface: rgba(15, 23, 42, 0.045);
--text: #101827;
--muted: #5f6b7a;
--border: rgba(15, 23, 42, 0.12);
--shadow: 0 24px 70px rgba(56, 65, 91, 0.18);
}
html[data-accent="cyan"] {
--accent: #06b6d4;
--accent-2: #3b82f6;
--accent-rgb: 6, 182, 212;
}
html[data-accent="lime"] {
--accent: #84cc16;
--accent-2: #14b8a6;
--accent-rgb: 132, 204, 22;
}
html[data-accent="amber"] {
--accent: #f59e0b;
--accent-2: #f43f5e;
--accent-rgb: 245, 158, 11;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background:
radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.24), transparent 36rem),
radial-gradient(circle at bottom right, rgba(34, 211, 238, 0.16), transparent 34rem),
linear-gradient(135deg, var(--bg), var(--bg-soft));
color: var(--text);
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
transition: background 220ms ease, color 220ms ease;
}
button,
input,
a {
font: inherit;
}
a {
color: inherit;
}
.ambient {
position: fixed;
z-index: -1;
width: 30rem;
height: 30rem;
border-radius: 999px;
filter: blur(18px);
opacity: 0.42;
pointer-events: none;
}
.ambient-one {
top: -10rem;
right: -8rem;
background: rgba(var(--accent-rgb), 0.46);
}
.ambient-two {
bottom: -13rem;
left: -10rem;
background: rgba(34, 211, 238, 0.3);
}
.shell {
width: min(1320px, calc(100% - 32px));
margin: 0 auto;
padding: 42px 0;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(330px, 460px);
gap: 24px;
align-items: stretch;
margin-bottom: 18px;
}
.hero-content,
.control-panel,
.overview,
.catalog-section,
.empty-state {
border: 1px solid var(--border);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(22px);
}
.hero-content {
min-height: 330px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: clamp(28px, 5vw, 56px);
border-radius: 38px;
position: relative;
overflow: hidden;
}
.hero-content::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(var(--accent-rgb), 0.28), transparent 34%),
url("data:image/svg+xml,%3Csvg width='140' height='140' viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' stroke='white' stroke-opacity='.09'%3E%3Cpath d='M0 70h140M70 0v140'/%3E%3Ccircle cx='70' cy='70' r='38'/%3E%3C/g%3E%3C/svg%3E");
mask-image: linear-gradient(to bottom, black, transparent 88%);
pointer-events: none;
}
.hero-content > * {
position: relative;
}
.eyebrow,
.group-number,
.item-number {
margin: 0;
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 840px;
margin: 14px 0 18px;
font-size: clamp(2.6rem, 8vw, 6.4rem);
line-height: 0.9;
letter-spacing: -0.08em;
}
.lead {
max-width: 740px;
margin: 0;
color: var(--muted);
font-size: clamp(1rem, 2vw, 1.2rem);
line-height: 1.7;
}
.control-panel {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 28px;
}
.search-field {
display: grid;
gap: 8px;
}
.search-field span,
.control-group > span {
color: var(--muted);
font-size: 0.82rem;
font-weight: 800;
}
.search-field input {
width: 100%;
min-height: 48px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
color: var(--text);
outline: none;
padding: 0 16px;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.search-field input:focus {
border-color: rgba(var(--accent-rgb), 0.62);
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16);
background: var(--panel-strong);
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
}
.control-group > span {
min-width: 68px;
padding-left: 6px;
}
.segmented,
.accent-swatch {
border: 0;
cursor: pointer;
transition: transform 160ms ease, background 160ms ease, color 160ms ease;
}
.segmented {
flex: 1;
min-height: 34px;
border-radius: 12px;
background: transparent;
color: var(--muted);
font-size: 0.88rem;
font-weight: 800;
}
.segmented:hover,
.accent-swatch:hover {
transform: translateY(-1px);
}
.segmented.is-active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
}
.accent-group {
justify-content: space-between;
}
.accent-swatch {
width: 36px;
height: 36px;
border: 2px solid transparent;
border-radius: 999px;
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.18);
}
.accent-swatch[data-accent="violet"] {
background: linear-gradient(135deg, #8b5cf6, #22d3ee);
}
.accent-swatch[data-accent="cyan"] {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
}
.accent-swatch[data-accent="lime"] {
background: linear-gradient(135deg, #84cc16, #14b8a6);
}
.accent-swatch[data-accent="amber"] {
background: linear-gradient(135deg, #f59e0b, #f43f5e);
}
.accent-swatch.is-active {
border-color: var(--text);
transform: scale(1.05);
}
.overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
margin-bottom: 18px;
border-radius: 26px;
overflow: hidden;
}
.overview div {
padding: 18px 20px;
background: rgba(255, 255, 255, 0.035);
}
.overview span {
display: block;
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 850;
line-height: 1;
letter-spacing: -0.06em;
}
.overview p {
margin: 8px 0 0;
color: var(--muted);
font-weight: 700;
}
.catalog {
display: grid;
gap: 18px;
}
.catalog-section {
border-radius: 32px;
overflow: hidden;
}
.section-head {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
padding: 24px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.14), transparent 22rem),
rgba(255, 255, 255, 0.035);
}
.section-head h2,
.group-head h3,
.nav-card h4,
.empty-state h2 {
margin: 0;
color: var(--text);
letter-spacing: -0.04em;
}
.section-head h2 {
margin-top: 8px;
font-size: clamp(1.6rem, 4vw, 2.6rem);
}
.section-head p:not(.eyebrow),
.group-head p:not(.group-number) {
max-width: 760px;
margin: 8px 0 0;
color: var(--muted);
line-height: 1.65;
}
.section-count {
white-space: nowrap;
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
color: var(--muted);
background: var(--surface);
font-size: 0.86rem;
font-weight: 800;
}
.section-groups {
display: grid;
gap: 18px;
padding: 18px;
}
.catalog-group {
border: 1px solid var(--border);
border-radius: 24px;
background: rgba(255, 255, 255, 0.035);
overflow: hidden;
}
.group-head {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px;
border-bottom: 1px solid var(--border);
}
.group-head h3 {
margin-top: 6px;
font-size: 1.22rem;
}
.group-head > span {
width: 36px;
height: 36px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.14);
color: var(--accent);
font-weight: 900;
}
.items-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 14px;
}
.nav-card {
--tilt-x: 0deg;
--tilt-y: 0deg;
position: relative;
min-height: 224px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 22px;
background: var(--panel);
color: var(--text);
text-decoration: none;
overflow: hidden;
transform-origin: center;
transform: perspective(900px) rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
will-change: transform;
transition:
transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 220ms ease,
background 220ms ease,
box-shadow 220ms ease;
}
.nav-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
circle at var(--card-x) var(--card-y),
rgba(var(--accent-rgb), 0.24),
transparent 34%
);
opacity: 0;
transition: opacity 180ms ease;
pointer-events: none;
}
.nav-card.is-hovered,
.nav-card:focus-visible {
border-color: rgba(var(--accent-rgb), 0.52);
background: var(--panel-strong);
box-shadow: 0 22px 70px rgba(var(--accent-rgb), 0.16);
outline: none;
}
.nav-card.is-hovered::before,
.nav-card:focus-visible::before {
opacity: 1;
}
.nav-card img {
width: 58px;
height: 58px;
object-fit: contain;
filter: drop-shadow(0 14px 24px rgba(0, 0, 0, 0.22));
}
.card-content {
position: relative;
display: grid;
gap: 7px;
}
.nav-card h4 {
font-size: 1.08rem;
line-height: 1.25;
}
.item-type {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
font-weight: 750;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.tags span {
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 8px;
background: rgba(var(--accent-rgb), 0.1);
color: var(--muted);
font-size: 0.76rem;
font-weight: 800;
}
.arrow {
position: absolute;
right: 16px;
bottom: 14px;
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.14);
color: var(--accent);
font-size: 1.15rem;
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
}
.nav-card.is-hovered .arrow,
.nav-card:focus-visible .arrow {
transform: translateX(4px);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
}
.shine {
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
transparent 0%,
rgba(255, 255, 255, 0.16) 42%,
transparent 64%
);
transform: translateX(-130%);
transition: transform 540ms ease;
pointer-events: none;
}
.nav-card.is-hovered .shine,
.nav-card:focus-visible .shine {
transform: translateX(130%);
}
.catalog.list-view .items-grid {
grid-template-columns: 1fr;
}
.catalog.list-view .nav-card {
min-height: 112px;
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 42px;
align-items: center;
gap: 16px;
}
.catalog.list-view .arrow {
position: static;
justify-self: end;
}
.empty-state {
min-height: 280px;
display: grid;
place-items: center;
align-content: center;
gap: 12px;
padding: 36px;
border-radius: 30px;
text-align: center;
}
.empty-state img {
width: 74px;
height: 74px;
}
.empty-state p {
max-width: 480px;
margin: 0;
color: var(--muted);
}
@media (max-width: 1120px) {
.hero {
grid-template-columns: 1fr;
}
.items-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.shell {
width: min(100% - 22px, 1320px);
padding: 22px 0;
}
.hero-content {
min-height: 300px;
border-radius: 28px;
}
.control-panel,
.catalog-section,
.empty-state {
border-radius: 24px;
}
.control-group {
flex-wrap: wrap;
}
.control-group > span {
width: 100%;
min-width: 0;
}
.accent-group > span {
width: auto;
}
.overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.section-head,
.group-head {
display: grid;
}
.section-count {
width: fit-content;
}
.section-groups {
padding: 12px;
}
.items-grid {
grid-template-columns: 1fr;
padding: 12px;
}
.nav-card {
min-height: 210px;
}
.catalog.list-view .nav-card {
grid-template-columns: 48px minmax(0, 1fr);
min-height: 136px;
}
.catalog.list-view .arrow {
position: absolute;
}
.nav-card img {
width: 50px;
height: 50px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}