823 lines
27 KiB
HTML
823 lines
27 KiB
HTML
<!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">×</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>
|