// ============================================================================
// Persistent storage keys
// ============================================================================
// STORAGE_KEY хранит пользовательские настройки интерфейса:
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
const STORAGE_KEY = "ecosystem-panel-preferences";
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
// отдельно для дерева навигации и отдельно для контентных секций.
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
// ============================================================================
// Panel configuration (settings.json)
// ============================================================================
// settings.json задает источник данных панели:
// - dataUrl: URL или путь к JSON (fetch), например "./panel-data.json"
// - data: inline-объект с той же структурой, если dataUrl пустой
const SETTINGS_PATH = "./settings.json";
let ecosystemSections = [];
let panelTitle = "Панель экосистемы";
const defaultServiceMeta = {
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" },
};
let serviceMeta = { ...defaultServiceMeta };
// ============================================================================
// 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,
treeTwoColumn: false,
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 treeTwoColumnInput = document.querySelector("[data-tree-two-column]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const emptyTemplate = document.querySelector("#empty-template");
const configErrorTemplate = document.querySelector("#config-error-template");
const pageTitleElement = document.querySelector("#page-title");
// ============================================================================
// Initial render
// ============================================================================
bootstrap();
async function bootstrap() {
try {
const panelData = await loadPanelConfig();
ecosystemSections = panelData.sections;
serviceMeta = panelData.serviceMeta;
panelTitle = panelData.title;
pageTitleElement.textContent = panelTitle;
document.title = panelTitle;
} catch (error) {
showConfigError(error);
return;
}
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;
if (preferences.showTreeTags) {
preferences.treeTwoColumn = false;
}
applyPreferences(preferences);
savePreferences(preferences);
});
// Компактный двухколоночный режим элементов внутри папок дерева.
// Взаимоисключимо с отображением тегов.
treeTwoColumnInput.addEventListener("change", () => {
preferences.treeTwoColumn = treeTwoColumnInput.checked;
if (preferences.treeTwoColumn) {
preferences.showTreeTags = false;
if (preferences.sidebarWidth < 480) {
preferences.sidebarWidth = 480;
}
}
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, getSidebarMaxWidth(preferences)),
);
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);
});
// ============================================================================
// Panel config loading
// ============================================================================
async function loadPanelConfig(settingsPath = SETTINGS_PATH) {
const settings = await fetchJson(settingsPath);
const dataUrl = typeof settings.dataUrl === "string" ? settings.dataUrl.trim() : "";
let panelSource = settings.data;
if (dataUrl) {
panelSource = await fetchJson(dataUrl);
}
if (!panelSource || typeof panelSource !== "object") {
throw new Error("settings.json должен содержать data или dataUrl");
}
return normalizePanelData(panelSource);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Не удалось загрузить ${url}: HTTP ${response.status}`);
}
return response.json();
}
function normalizePanelData(raw) {
const sections = Array.isArray(raw.sections) ? raw.sections.map(normalizeSection) : [];
if (!sections.length) {
throw new Error("В данных панели нет sections");
}
return {
title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "Панель экосистемы",
sections,
serviceMeta: {
...defaultServiceMeta,
...(raw.serviceMeta && typeof raw.serviceMeta === "object" ? raw.serviceMeta : {}),
},
};
}
function normalizeSection(section) {
if (!section?.id || !section?.title) {
throw new Error("Каждый section должен содержать id и title");
}
return {
id: String(section.id),
number: String(section.number ?? ""),
title: String(section.title),
description: String(section.description ?? ""),
groups: Array.isArray(section.groups) ? section.groups.map(normalizeGroup) : [],
};
}
function normalizeGroup(group) {
if (!group?.id || !group?.title) {
throw new Error("Каждый group должен содержать id и title");
}
return {
id: String(group.id),
number: String(group.number ?? ""),
title: String(group.title),
description: String(group.description ?? ""),
items: Array.isArray(group.items) ? group.items.map(normalizeItemFromConfig) : [],
};
}
function normalizeItemFromConfig(item) {
if (!item?.title || !item?.type) {
throw new Error("Каждый item должен содержать title и type");
}
return {
number: String(item.number ?? ""),
title: String(item.title),
type: String(item.type),
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
url: typeof item.url === "string" && item.url.trim() ? item.url.trim() : "#",
};
}
function showConfigError(error) {
const message = error instanceof Error ? error.message : String(error);
navTree.replaceChildren();
catalog.replaceChildren();
if (configErrorTemplate) {
const node = configErrorTemplate.content.cloneNode(true);
node.querySelector("[data-config-error-message]").textContent =
`${message}. Проверь settings.json и доступность dataUrl.`;
catalog.append(node);
return;
}
catalog.textContent = message;
}
// ============================================================================
// Data helpers
// ============================================================================
// Упрощает запись item-объектов при ручной правке JS (legacy helper).
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)}