Загрузить файлы в «/»

This commit is contained in:
2026-09-13 08:16:26 +00:00
parent c309f23f67
commit a0e9501d3f

822
backup-checklist.html Normal file
View File

@@ -0,0 +1,822 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Чек-лист администратора резервного копирования</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #14171a;
--surface: #1b2025;
--surface-2: #21272e;
--border: #2d343c;
--text: #e6e9ed;
--text-dim: #97a1ac;
--text-faint: #626b75;
--accent: #4fd1c5;
--accent-dim: #2c7a72;
--warn: #e8a33d;
--warn-dim: #8a611f;
--radius: 3px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
padding: 0 0 80px;
}
.mono { font-family: 'IBM Plex Mono', monospace; }
/* header */
.header {
position: sticky;
top: 0;
z-index: 10;
background: rgba(20,23,26,0.94);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--border);
padding: 18px 24px 16px;
}
.header-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
max-width: 760px;
margin: 0 auto;
}
h1 {
font-size: 17px;
font-weight: 600;
margin: 0;
letter-spacing: 0.2px;
}
.subtitle {
color: var(--text-dim);
font-size: 13px;
margin-top: 3px;
}
.overall-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: var(--accent);
white-space: nowrap;
}
.overall-count .skipped-note {
color: var(--text-faint);
font-size: 11.5px;
display: block;
text-align: right;
margin-top: 2px;
}
.progress-track {
max-width: 760px;
margin: 12px auto 0;
height: 3px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.4s ease;
}
/* layout */
.wrap {
max-width: 760px;
margin: 28px auto 0;
padding: 0 24px;
}
.section {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 14px;
background: var(--surface);
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
cursor: pointer;
user-select: none;
border-left: 2px solid var(--border);
}
.section.crit .section-header { border-left-color: var(--warn-dim); }
.section-header:hover { background: var(--surface-2); }
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.freq-tag {
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.3px;
color: var(--text-dim);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
padding: 2px 6px;
flex-shrink: 0;
}
.section.crit .freq-tag { color: var(--warn); border-color: var(--warn-dim); }
.section-title {
font-size: 14.5px;
font-weight: 500;
}
.section-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.section-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
color: var(--text-faint);
}
.section-count.done { color: var(--accent); }
.chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint);
transform: rotate(-45deg);
transition: transform 0.2s ease;
margin-right: 2px;
}
.section.open .chevron { transform: rotate(45deg); }
.section-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.section.open .section-body {
max-height: 2000px;
}
.section-body-inner {
padding: 2px 16px 12px;
border-top: 1px solid var(--border);
}
.item {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 11px 0;
border-bottom: 1px solid var(--border);
}
.item:last-child { border-bottom: none; }
.checkbox {
width: 16px;
height: 16px;
border: 1.5px solid var(--text-faint);
border-radius: 3px;
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
position: relative;
transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
}
.checkbox:hover { border-color: var(--accent); }
.checkbox.checked {
background: var(--accent);
border-color: var(--accent);
}
.checkbox.checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border-right: 2px solid #0e1214;
border-bottom: 2px solid #0e1214;
transform: rotate(40deg);
}
.checkbox.skipped {
opacity: 0.35;
cursor: default;
border-style: dashed;
}
.item-text { flex: 1; min-width: 0; }
.item-title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.item-title {
font-size: 13.8px;
line-height: 1.4;
cursor: pointer;
transition: color 0.15s ease;
}
.item.done .item-title {
color: var(--text-faint);
text-decoration: line-through;
text-decoration-color: var(--text-faint);
}
.item.skipped .item-title {
color: var(--text-faint);
}
.na-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: 0.3px;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 2px;
padding: 1px 5px;
flex-shrink: 0;
}
.item-desc {
font-size: 12.5px;
color: var(--text-dim);
line-height: 1.5;
margin-top: 3px;
}
.item.done .item-desc,
.item.skipped .item-desc { color: var(--text-faint); }
.skip-toggle {
background: none;
border: none;
color: var(--text-faint);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
cursor: pointer;
padding: 2px 0;
flex-shrink: 0;
align-self: flex-start;
margin-top: 2px;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.skip-toggle:hover { color: var(--text-dim); }
.item.skipped .skip-toggle { color: var(--warn); }
.reset-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-faint);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
padding: 3px 8px;
border-radius: 2px;
cursor: pointer;
margin-top: 8px;
}
.reset-btn:hover { color: var(--text-dim); border-color: var(--text-dim); }
.footer-bar {
max-width: 760px;
margin: 20px auto 0;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.footer-note {
font-size: 11.5px;
color: var(--text-faint);
}
.footer-actions { display: flex; gap: 8px; }
.btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: 'IBM Plex Mono', monospace;
font-size: 11.5px;
padding: 6px 11px;
border-radius: 2px;
cursor: pointer;
}
.btn:hover { border-color: var(--text-dim); color: var(--text); }
.btn.warn-hover:hover { border-color: var(--warn-dim); color: var(--warn); }
.btn.accent {
border-color: var(--accent-dim);
color: var(--accent);
}
.btn.accent:hover { border-color: var(--accent); background: rgba(79,209,197,0.08); }
.loading {
text-align: center;
color: var(--text-faint);
font-size: 13px;
padding: 40px 0;
font-family: 'IBM Plex Mono', monospace;
}
/* report modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(10,12,14,0.7);
z-index: 50;
padding: 24px;
overflow-y: auto;
}
.modal-overlay.open { display: block; }
.modal {
max-width: 640px;
margin: 20px auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 14.5px;
font-weight: 600;
margin: 0;
}
.modal-close {
background: none;
border: none;
color: var(--text-faint);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
}
.modal-close:hover { color: var(--text); }
.report-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
padding: 16px 18px;
max-height: 55vh;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--border);
}
.copy-confirm {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--accent);
align-self: center;
margin-right: auto;
opacity: 0;
transition: opacity 0.2s ease;
}
.copy-confirm.show { opacity: 1; }
@media print {
body * { visibility: hidden; }
.report-text, .report-text * { visibility: visible; }
.report-text {
position: absolute;
left: 0; top: 0;
width: 100%;
max-height: none;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-top">
<div>
<h1>Чек-лист резервного копирования</h1>
<div class="subtitle">Регламентные проверки и восстановление</div>
</div>
<div class="overall-count mono" id="overallCount">0 / 0</div>
</div>
<div class="progress-track"><div class="progress-fill" id="overallFill"></div></div>
</div>
<div class="wrap" id="sections">
<div class="loading">Загрузка…</div>
</div>
<div class="footer-bar">
<div class="footer-note">Прогресс сохраняется автоматически</div>
<div class="footer-actions">
<button class="btn accent" id="reportBtn">Отчёт для ППР</button>
<button class="btn warn-hover" id="resetAllBtn">Сбросить всё</button>
</div>
</div>
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h2>Отчёт по плановому обслуживанию</h2>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="report-text" id="reportText"></div>
<div class="modal-footer">
<span class="copy-confirm" id="copyConfirm">Скопировано</span>
<button class="btn" id="printBtn">Печать</button>
<button class="btn accent" id="copyBtn">Копировать</button>
</div>
</div>
</div>
<script>
const DATA = [
{
id: 'daily',
title: 'Ежедневные проверки',
freq: 'ЕЖЕДНЕВНО',
crit: false,
items: [
{ id: 'd1', title: 'Проверить статус всех ночных заданий бэкапа', desc: 'Убедиться, что все задания завершились успешно, отметить сбойные и зависшие.' },
{ id: 'd2', title: 'Просмотреть журналы на ошибки и предупреждения', desc: 'Логи агентов резервного копирования и сервера управления.' },
{ id: 'd3', title: 'Проверить свободное место в хранилище', desc: 'Дисковые пулы, дедупликация, ленточная библиотека, облачный бакет.' },
{ id: 'd4', title: 'Проверить репликацию в offsite / DR-хранилище', desc: 'Убедиться, что копии доехали до второй площадки без задержек.' },
{ id: 'd5', title: 'Разобрать алерты системы мониторинга', desc: 'Закрыть или эскалировать открытые инциденты за сутки.' }
]
},
{
id: 'weekly',
title: 'Еженедельные проверки',
freq: 'ЕЖЕНЕДЕЛЬНО',
crit: false,
items: [
{ id: 'w1', title: 'Выполнить тестовое восстановление отдельного файла', desc: 'Случайная система из списка защищаемых — проверка "по кругу".' },
{ id: 'w2', title: 'Проверить целостность бэкапов (verify / checksum)', desc: 'Запустить встроенную проверку целостности, если не выполняется автоматически.' },
{ id: 'w3', title: 'Проверить ротацию носителей', desc: 'Ленты, снапшоты, архивные копии — соответствие схеме ротации.' },
{ id: 'w4', title: 'Проверить окна резервного копирования', desc: 'Нет ли пересечений заданий и превышения бэкап-окна.' },
{ id: 'w5', title: 'Актуализировать документацию по изменениям', desc: 'Новые системы, изменённые расписания, изменения в политике.' }
]
},
{
id: 'monthly',
title: 'Ежемесячные проверки',
freq: 'ЕЖЕМЕСЯЧНО',
crit: false,
items: [
{ id: 'm1', title: 'Полное тестовое восстановление одной системы', desc: 'Восстановление в изолированную среду с проверкой работоспособности.' },
{ id: 'm2', title: 'Сверить соответствие RPO/RTO для критичных систем', desc: 'Сравнить фактические показатели с требованиями бизнеса.' },
{ id: 'm3', title: 'Аудит списка защищаемых систем', desc: 'Найти новые серверы и сервисы, не включённые в бэкап.' },
{ id: 'm4', title: 'Проверить сроки лицензий и сертификатов ПО', desc: 'Система резервного копирования, агенты, хранилище.' },
{ id: 'm5', title: 'Очистка устаревших копий по политике хранения', desc: 'Освободить место согласно retention policy, зафиксировать удаление.' }
]
},
{
id: 'quarterly',
title: 'Ежеквартальные проверки',
freq: 'ЕЖЕКВАРТАЛЬНО',
crit: false,
items: [
{ id: 'q1', title: 'Полноценные учения по аварийному восстановлению (DR drill)', desc: 'С участием ответственных команд, по заранее утверждённому сценарию.' },
{ id: 'q2', title: 'Пересмотреть политику резервного копирования', desc: 'Актуальность расписаний, retention, приоритетов систем.' },
{ id: 'q3', title: 'Проверить offsite / облачное хранилище на соответствие требованиям', desc: 'Договор, гео-репликация, требования регуляторов при наличии.' },
{ id: 'q4', title: 'Обновить контакты и процедуру эскалации', desc: 'Актуальный список ответственных и порядок оповещения при инциденте.' },
{ id: 'q5', title: 'Проверить шифрование бэкапов и управление ключами', desc: 'Ротация ключей, доступ к хранилищу ключей, резервная копия ключей.' }
]
},
{
id: 'incident',
title: 'При инциденте потери данных',
freq: 'ПО СОБЫТИЮ',
crit: true,
items: [
{ id: 'i1', title: 'Зафиксировать время и масштаб инцидента', desc: 'Какие системы затронуты, с какого момента.' },
{ id: 'i2', title: 'Определить последнюю валидную точку восстановления', desc: 'Проверить, что копия прошла верификацию целостности.' },
{ id: 'i3', title: 'Уведомить заинтересованные стороны', desc: 'По утверждённой процедуре эскалации.' },
{ id: 'i4', title: 'Восстановить в изолированной среде для проверки', desc: 'Не восстанавливать сразу в прод без подтверждения целостности.' },
{ id: 'i5', title: 'Восстановить в продакшн после подтверждения', desc: 'С контролем на этапе переключения и наблюдением после.' },
{ id: 'i6', title: 'Задокументировать инцидент (post-mortem)', desc: 'Причина, хронология, план предотвращения повторения.' }
]
},
{
id: 'onboarding',
title: 'Подключение новой системы',
freq: 'ПО СОБЫТИЮ',
crit: false,
items: [
{ id: 'o1', title: 'Определить критичность системы (RPO/RTO)', desc: 'Согласовать требования с владельцем системы.' },
{ id: 'o2', title: 'Настроить задание резервного копирования', desc: 'Расписание, тип бэкапа, место хранения.' },
{ id: 'o3', title: 'Проверить первое полное резервное копирование', desc: 'Убедиться в успешном завершении и корректном объёме данных.' },
{ id: 'o4', title: 'Выполнить тестовое восстановление', desc: 'Подтвердить, что данные реально восстанавливаются.' },
{ id: 'o5', title: 'Добавить систему в мониторинг и документацию', desc: 'Алерты на сбой задания, запись в реестре защищаемых систем.' }
]
}
];
// state[itemId] = 'done' | 'skipped' | undefined (pending)
const STORAGE_KEY = 'backup-checklist-state-v2';
let state = {};
let openSections = new Set(['daily']);
async function loadState() {
try {
const result = await window.storage.get(STORAGE_KEY, false);
if (result && result.value) {
state = JSON.parse(result.value);
}
} catch (e) {
state = {};
}
}
async function saveState() {
try {
await window.storage.set(STORAGE_KEY, JSON.stringify(state), false);
} catch (e) {
console.error('Не удалось сохранить состояние', e);
}
}
function sectionStats(section) {
const total = section.items.length;
const skipped = section.items.filter(it => state[it.id] === 'skipped').length;
const done = section.items.filter(it => state[it.id] === 'done').length;
const applicable = total - skipped;
return { total, skipped, done, applicable };
}
function render() {
const container = document.getElementById('sections');
container.innerHTML = '';
let totalDone = 0, totalApplicable = 0, totalSkipped = 0;
DATA.forEach(section => {
const stats = sectionStats(section);
totalDone += stats.done;
totalApplicable += stats.applicable;
totalSkipped += stats.skipped;
const isOpen = openSections.has(section.id);
const secEl = document.createElement('div');
secEl.className = 'section' + (section.crit ? ' crit' : '') + (isOpen ? ' open' : '');
secEl.innerHTML = `
<div class="section-header" data-section="${section.id}">
<div class="section-header-left">
<span class="chevron"></span>
<span class="freq-tag">${section.freq}</span>
<span class="section-title">${section.title}</span>
</div>
<div class="section-header-right">
<span class="section-count${stats.applicable > 0 && stats.done === stats.applicable ? ' done' : ''}">${stats.done}/${stats.applicable}${stats.skipped ? ` <span class="mono">(н/д ${stats.skipped})</span>` : ''}</span>
</div>
</div>
<div class="section-body">
<div class="section-body-inner">
${section.items.map(it => {
const st = state[it.id];
const isDone = st === 'done';
const isSkipped = st === 'skipped';
return `
<div class="item${isDone ? ' done' : ''}${isSkipped ? ' skipped' : ''}" data-item="${it.id}">
<div class="checkbox${isDone ? ' checked' : ''}${isSkipped ? ' skipped' : ''}" data-item-cb="${it.id}"></div>
<div class="item-text">
<div class="item-title-row">
<div class="item-title" data-item-title="${it.id}">${it.title}</div>
${isSkipped ? '<span class="na-badge">Н/Д</span>' : ''}
</div>
<div class="item-desc">${it.desc}</div>
<button class="skip-toggle" data-skip="${it.id}">${isSkipped ? 'вернуть в список' : 'нет в этой инсталляции'}</button>
</div>
</div>
`;
}).join('')}
<button class="reset-btn" data-reset-section="${section.id}">Сбросить раздел</button>
</div>
</div>
`;
container.appendChild(secEl);
});
const overallCountEl = document.getElementById('overallCount');
overallCountEl.innerHTML = `${totalDone} / ${totalApplicable}` + (totalSkipped ? `<span class="skipped-note">н/д: ${totalSkipped}</span>` : '');
document.getElementById('overallFill').style.width = totalApplicable ? `${(totalDone / totalApplicable) * 100}%` : '0%';
container.querySelectorAll('.section-header').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.section;
if (openSections.has(id)) openSections.delete(id);
else openSections.add(id);
render();
});
});
container.querySelectorAll('[data-item-cb], [data-item-title]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const id = el.dataset.itemCb || el.dataset.itemTitle;
if (state[id] === 'skipped') return; // must un-skip via the skip toggle first
state[id] = state[id] === 'done' ? undefined : 'done';
if (state[id] === undefined) delete state[id];
saveState();
render();
});
});
container.querySelectorAll('[data-skip]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const id = el.dataset.skip;
if (state[id] === 'skipped') {
delete state[id];
} else {
state[id] = 'skipped';
}
saveState();
render();
});
});
container.querySelectorAll('[data-reset-section]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const section = DATA.find(s => s.id === el.dataset.resetSection);
section.items.forEach(it => { delete state[it.id]; });
saveState();
render();
});
});
}
document.getElementById('resetAllBtn').addEventListener('click', () => {
state = {};
saveState();
render();
});
// ---- Report for ППР ----
function buildReport() {
const now = new Date();
const dateStr = now.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
let lines = [];
lines.push('ОТЧЁТ О ПЛАНОВОМ ОБСЛУЖИВАНИИ СИСТЕМЫ РЕЗЕРВНОГО КОПИРОВАНИЯ');
lines.push(`Дата: ${dateStr} Время: ${timeStr}`);
lines.push('='.repeat(58));
let totalDone = 0, totalApplicable = 0, totalSkipped = 0, totalOpen = 0;
const openItemsList = [];
const skippedItemsList = [];
DATA.forEach(section => {
const stats = sectionStats(section);
totalDone += stats.done;
totalApplicable += stats.applicable;
totalSkipped += stats.skipped;
lines.push('');
lines.push(`${section.title} [${section.freq}]`);
lines.push(` Выполнено: ${stats.done}/${stats.applicable}` + (stats.skipped ? ` Не применимо: ${stats.skipped}` : ''));
section.items.forEach(it => {
const st = state[it.id];
let mark = '[ ]';
if (st === 'done') mark = '[x]';
else if (st === 'skipped') mark = '[—]';
lines.push(` ${mark} ${it.title}`);
if (st === 'skipped') skippedItemsList.push(`${section.title}: ${it.title}`);
if (!st) { totalOpen++; openItemsList.push(`${section.title}: ${it.title}`); }
});
});
lines.push('');
lines.push('='.repeat(58));
lines.push('ИТОГО');
const pct = totalApplicable ? Math.round((totalDone / totalApplicable) * 100) : 0;
lines.push(` Выполнено: ${totalDone} из ${totalApplicable} применимых пунктов (${pct}%)`);
lines.push(` Не применимо к инсталляции: ${totalSkipped}`);
lines.push(` Осталось выполнить: ${totalOpen}`);
if (openItemsList.length) {
lines.push('');
lines.push('Невыполненные пункты:');
openItemsList.forEach(t => lines.push(` - ${t}`));
}
if (skippedItemsList.length) {
lines.push('');
lines.push('Пункты, отмеченные как не применимые (Н/Д):');
skippedItemsList.forEach(t => lines.push(` - ${t}`));
}
lines.push('');
lines.push('-'.repeat(58));
lines.push('Ответственный: _____________________ Подпись: _____________');
return lines.join('\n');
}
const modalOverlay = document.getElementById('modalOverlay');
document.getElementById('reportBtn').addEventListener('click', () => {
document.getElementById('reportText').textContent = buildReport();
modalOverlay.classList.add('open');
});
document.getElementById('modalClose').addEventListener('click', () => modalOverlay.classList.remove('open'));
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) modalOverlay.classList.remove('open'); });
document.getElementById('printBtn').addEventListener('click', () => window.print());
document.getElementById('copyBtn').addEventListener('click', async () => {
const text = document.getElementById('reportText').textContent;
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
const confirmEl = document.getElementById('copyConfirm');
confirmEl.classList.add('show');
setTimeout(() => confirmEl.classList.remove('show'), 1500);
});
(async function init() {
await loadState();
render();
})();
</script>
</body>
</html>