Files
magistr/resume/script.js
2026-07-04 15:47:12 +03:00

200 lines
7.4 KiB
JavaScript
Raw Permalink 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.
// Интерактивная логика инженерного резюме Егора Зуева.
const incidentRunbooks = {
postgres: {
title: 'PostgreSQL: CPU 100% у одного тенанта',
steps: [
{
label: 'Локализация',
text: 'Проверяю SigNoz по `tenant.id`, затем на хосте БД смотрю `pg_stat_activity`, активные транзакции, блокировки и системную нагрузку.'
},
{
label: 'Восстановление',
text: 'Ограничиваю тяжелый запрос через `pg_cancel_backend`, проверяю пул HikariCP и возвращаю сервис в нормальный режим без затрагивания других тенантов.'
},
{
label: 'Профилактика',
text: 'Добавляю индекс или правлю запрос, включаю slow query logs и настраиваю алерт по CPU, активным сессиям и длительным транзакциям.'
}
]
},
tls: {
title: 'Caddy: TLS или 502 на новом домене',
steps: [
{
label: 'Локализация',
text: 'Проверяю DNS A/CNAME, логи Caddy через `journalctl`, доступность upstream-нод K3s и состояние Traefik Ingress.'
},
{
label: 'Восстановление',
text: 'Перезагружаю конфигурацию Caddy, проверяю rate limit ACME, при необходимости переключаю issuer и временно вывожу tenant из автопровижининга.'
},
{
label: 'Профилактика',
text: 'Добавляю предварительную проверку DNS перед созданием тенанта и мониторинг срока действия сертификатов через blackbox-проверки.'
}
]
},
runner: {
title: 'Gitea Actions: self-hosted runner завис',
steps: [
{
label: 'Локализация',
text: 'Смотрю `systemctl status`, `docker ps`, `docker logs`, свободное место через `df -h` и очередь job в Gitea Actions.'
},
{
label: 'Восстановление',
text: 'Очищаю зависшие контейнеры и кэш Docker, перезапускаю runner, затем вручную контролирую `kubectl rollout status` для backend/frontend.'
},
{
label: 'Профилактика',
text: 'Выношу runner на отдельный ZFS dataset с квотой, добавляю регулярную очистку слоев и алерты по свободному месту.'
}
]
}
};
document.addEventListener('DOMContentLoaded', () => {
initPrintButton();
initCopyButtons();
initReveal();
initNavigationHighlight();
initIncidentTabs();
});
function initPrintButton() {
const printButton = document.getElementById('btnPrint');
if (!printButton) return;
printButton.addEventListener('click', () => {
window.print();
});
}
function initCopyButtons() {
document.querySelectorAll('[data-copy]').forEach((element) => {
element.addEventListener('click', async () => {
const value = element.getAttribute('data-copy');
if (!value) return;
try {
await navigator.clipboard.writeText(value);
showToast('Скопировано: ' + value);
} catch (error) {
console.error('Ошибка копирования:', error);
showToast('Не удалось скопировать');
}
});
});
}
function initReveal() {
const revealElements = document.querySelectorAll('.reveal');
if (!revealElements.length) return;
if (!('IntersectionObserver' in window)) {
revealElements.forEach((element) => element.classList.add('active'));
return;
}
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('active');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.08,
rootMargin: '0px 0px -48px 0px'
});
revealElements.forEach((element) => observer.observe(element));
}
function initNavigationHighlight() {
const links = Array.from(document.querySelectorAll('.nav-link'));
const sections = links
.map((link) => document.querySelector(link.getAttribute('href')))
.filter(Boolean);
if (!links.length || !sections.length) return;
const setActiveLink = () => {
const currentSection = sections.reduce((current, section) => {
const sectionTop = section.getBoundingClientRect().top;
if (sectionTop <= 120) {
return section;
}
return current;
}, sections[0]);
links.forEach((link) => {
const isActive = link.getAttribute('href') === '#' + currentSection.id;
link.classList.toggle('active', isActive);
});
};
setActiveLink();
window.addEventListener('scroll', setActiveLink, { passive: true });
}
function initIncidentTabs() {
const tabs = document.querySelectorAll('.incident-tab');
const panel = document.getElementById('incidentPanel');
if (!tabs.length || !panel) return;
const renderIncident = (incidentId) => {
const runbook = incidentRunbooks[incidentId];
if (!runbook) return;
panel.innerHTML = `
<h3>${escapeHtml(runbook.title)}</h3>
<div class="incident-steps">
${runbook.steps.map((step) => `
<div class="incident-step">
<strong>${escapeHtml(step.label)}</strong>
<p>${formatRunbookText(step.text)}</p>
</div>
`).join('')}
</div>
`;
};
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tabs.forEach((item) => item.classList.remove('active'));
tab.classList.add('active');
renderIncident(tab.dataset.incident);
});
});
renderIncident(document.querySelector('.incident-tab.active')?.dataset.incident || 'postgres');
}
function formatRunbookText(text) {
return escapeHtml(text).replace(/`([^`]+)`/g, '<code>$1</code>');
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function showToast(message) {
const toast = document.getElementById('toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('show');
window.clearTimeout(window.resumeToastTimer);
window.resumeToastTimer = window.setTimeout(() => {
toast.classList.remove('show');
}, 2400);
}