// ============================================================================
// Persistent storage keys
// ============================================================================
// STORAGE_KEY хранит пользовательские настройки интерфейса:
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
const STORAGE_KEY = "ecosystem-panel-preferences";
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
// отдельно для дерева навигации и отдельно для контентных секций.
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
// ============================================================================
// Navigation data model
// ============================================================================
// Меняй навигацию здесь: разделы -> подразделы -> элементы.
// url можно заменить на реальные адреса, tags помогают поиску.
//
// Структура:
// - section: крупный раздел панели;
// - group: подраздел внутри section;
// - item: конечный кликабельный элемент.
//
// Каждый item имеет:
// - number: человекочитаемый номер;
// - title: название карточки;
// - type: ключ из serviceMeta, который задает иконку и тип сервиса;
// - tags: поисковые теги, которые также можно показывать в дереве;
// - url: ссылка перехода. По умолчанию "#", чтобы заглушки не дергали страницу.
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"]),
],
},
],
},
];
// Метаданные типов сервисов.
// item.type ссылается на ключ здесь: так карточки получают label и icon.
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" },
};
// ============================================================================
// Default UI preferences
// ============================================================================
// Значения используются при первом открытии или если localStorage пуст/битый.
const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards",
accent: "violet",
customAccentFrom: "#8b5cf6",
customAccentTo: "#22d3ee",
customAccentAngle: 135,
showNumbers: false,
showTreeTags: true,
autoCollapseSearch: true,
sidebarWidth: 390,
};
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const availableAccents = ["violet", "cyan", "lime", "amber", "custom"];
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса, объединенные с localStorage.
const preferences = { ...defaultPreferences, ...readPreferences() };
// collapseState - объект вида { [collapseKey]: boolean } для details.
const collapseState = readCollapseState();
// Корневые DOM-узлы, которые нужны для рендера и событий.
const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
const settingsOverlay = document.querySelector("[data-settings-overlay]");
const settingsClose = document.querySelector("[data-settings-close]");
const themeButtons = document.querySelectorAll("[data-theme]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const accentCustomPanel = document.querySelector("[data-accent-custom-panel]");
const customAccentFromInput = document.querySelector("[data-custom-accent-from]");
const customAccentToInput = document.querySelector("[data-custom-accent-to]");
const customAccentAngleInput = document.querySelector("[data-custom-accent-angle]");
const customAccentAngleValue = document.querySelector("[data-custom-accent-angle-value]");
const customAccentPreview = document.querySelector("[data-custom-accent-preview]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const emptyTemplate = document.querySelector("#empty-template");
// ============================================================================
// Initial render
// ============================================================================
// Сначала строим дерево, затем применяем настройки, затем рендерим каталог.
// Такой порядок нужен, потому что applyPreferences выставляет data-атрибуты
// и CSS-переменные, от которых зависит внешний вид уже созданного DOM.
renderNavigationTree();
applyPreferences(preferences);
renderCatalog();
// ============================================================================
// Event bindings
// ============================================================================
// Поиск перерисовывает и дерево, и каталог:
// - дерево получает подсветку совпадений и авто-раскрытие веток;
// - каталог фильтруется и тоже подсвечивает найденный текст.
searchInput.addEventListener("input", () => {
renderNavigationTree();
renderCatalog();
});
// Клик по файлу/папке в дереве фокусирует соответствующую секцию/карточку.
// Само раскрытие details остается на нативном summary/caret.
navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]");
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
focusCatalogTarget(button.dataset.focusTarget);
});
// Сохраняем ручное раскрытие/сворачивание дерева и контента.
// Используем capture=true, потому что toggle не всплывает как обычный click.
navTree.addEventListener("toggle", handleDetailsToggle, true);
catalog.addEventListener("toggle", handleDetailsToggle, true);
// Открытие popup-настроек по шестеренке.
settingsToggle.addEventListener("click", () => {
const isOpen = settingsPanel.classList.toggle("is-open");
settingsOverlay.classList.toggle("is-open", isOpen);
settingsToggle.setAttribute("aria-expanded", String(isOpen));
});
// Закрытие popup-настроек всеми привычными способами.
settingsClose.addEventListener("click", closeSettingsPanel);
settingsOverlay.addEventListener("click", closeSettingsPanel);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSettingsPanel();
}
});
document.addEventListener("click", (event) => {
if (!settingsPanel.classList.contains("is-open")) {
return;
}
if (settingsPanel.contains(event.target) || settingsToggle.contains(event.target)) {
return;
}
closeSettingsPanel();
});
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);
});
});
// Изменение параметров своего градиента сразу переключает accent на custom.
function handleCustomAccentChange() {
preferences.customAccentFrom = customAccentFromInput.value;
preferences.customAccentTo = customAccentToInput.value;
preferences.customAccentAngle = Number(customAccentAngleInput.value);
preferences.accent = "custom";
applyPreferences(preferences);
savePreferences(preferences);
}
customAccentFromInput.addEventListener("input", handleCustomAccentChange);
customAccentToInput.addEventListener("input", handleCustomAccentChange);
customAccentAngleInput.addEventListener("input", handleCustomAccentChange);
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => {
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
// Показывает/скрывает поисковые теги в файловом дереве.
showTreeTagsInput.addEventListener("change", () => {
preferences.showTreeTags = showTreeTagsInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => {
preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences);
renderNavigationTree();
savePreferences(preferences);
});
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => {
event.preventDefault();
document.body.classList.add("is-resizing-sidebar");
sidebarResizer.setPointerCapture(event.pointerId);
const handlePointerMove = (moveEvent) => {
const layoutLeft = document.querySelector(".app-layout").getBoundingClientRect().left;
preferences.sidebarWidth = Math.round(clamp(moveEvent.clientX - layoutLeft, 260, 560));
applyPreferences(preferences);
};
const handlePointerUp = () => {
document.body.classList.remove("is-resizing-sidebar");
savePreferences(preferences);
sidebarResizer.removeEventListener("pointermove", handlePointerMove);
sidebarResizer.removeEventListener("pointerup", handlePointerUp);
sidebarResizer.removeEventListener("pointercancel", handlePointerUp);
};
sidebarResizer.addEventListener("pointermove", handlePointerMove);
sidebarResizer.addEventListener("pointerup", handlePointerUp);
sidebarResizer.addEventListener("pointercancel", handlePointerUp);
});
// ============================================================================
// Data helpers
// ============================================================================
// Упрощает запись item-объектов в ecosystemSections.
function createItem(number, title, type, tags = [], url = "#") {
return { number, title, type, tags, url };
}
// ============================================================================
// Catalog rendering
// ============================================================================
// Рендерит правую часть: details-разделы, details-подразделы и карточки.
// При активном поиске:
// - оставляет только группы с найденными элементами;
// - принудительно раскрывает найденные ветки;
// - подсвечивает совпадения через highlightText().
function renderCatalog() {
const query = normalize(searchInput.value);
const forceOpen = Boolean(query);
const fragment = document.createDocumentFragment();
let visibleItems = 0;
ecosystemSections.forEach((section) => {
const sectionElement = createSectionElement(section, forceOpen, query);
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, forceOpen, query));
});
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));
}
}
// Создает корневой details для раздела контента.
// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо
// от сохраненного collapsed-состояния.
function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section);
sectionElement.dataset.collapseKey = sectionElement.id;
sectionElement.tabIndex = -1;
sectionElement.open = forceOpen || getSavedOpenState(sectionElement.dataset.collapseKey);
sectionElement.innerHTML = `
${highlightText(section.number, query)} раздел ${highlightText(section.description, query)}${highlightText(section.title, query)}
${highlightText(group.number, query)}
${highlightText(group.description, query)}
${highlightText(item.number, query)}
${highlightText(meta.label, query)}