Резервное копирование bj на SMB: настройки (хранилище, расписание, хранение), копия сейчас, список и проверка копий; восстановление из копии на шаге 1 мастера; таймер bj-backup, smbclient в установщике

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 14:55:38 +03:00
parent e31e35e663
commit 6aecac1cc8
15 changed files with 558 additions and 4 deletions
@@ -17,6 +17,7 @@
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="backup"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="2.5"/><path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/><path d="M12 17v4M9 19l3 2 3-2"/></svg></span>Резервное копирование{{$bs := backupStatus}}{{if and .Settings.Backup.Enabled $bs}}{{if not $bs.OK}}<span class="ind err"></span>{{end}}{{else if not .Settings.Backup.Enabled}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
{{if devOn}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
@@ -988,6 +989,72 @@
</section>
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
{{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}
<section class="settings-section" id="sec-backup">
<h1>Резервное копирование</h1>
{{$b := .Settings.Backup}}{{$bs := backupStatus}}
<div class="card">
<h2><span class="dot {{if not $b.Enabled}}warn{{else if and $bs $bs.OK}}ok{{else}}err{{end}}"></span>Копии сервера bj на сетевое хранилище</h2>
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).</p>
<p style="margin:8px 0">{{if $bs}}Последняя копия: <b>{{$bs.At}}</b> — {{if $bs.OK}}<span style="color:var(--ok)">✓ {{$bs.Dir}}, {{$bs.Msg}}</span>{{else}}<span style="color:var(--err)">✗ {{$bs.Msg}}</span>{{end}}{{else}}<span class="muted">Копий ещё не было.</span>{{end}}</p>
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" style="width:auto"{{if $b.Enabled}} checked{{end}}> <b>Делать копии по расписанию</b></label>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//10.10.10.11/Backup"></label>
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="BJ_Backup/bj"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" autocomplete="new-password"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Как часто<select name="every_hours">{{$e := $b.EveryHours}}{{if not $e}}{{$e = 6}}{{end}}{{range $h := intList 1 2 3 4 6 8 12 24}}<option value="{{$h}}"{{if eq $h $e}} selected{{end}}>{{if eq $h 24}}раз в сутки (02:00){{else}}каждые {{$h}} ч{{end}}</option>{{end}}</select></label>
<label>Хранить, дней<input name="keep_days" type="number" min="1" max="365" value="{{if $b.KeepDays}}{{$b.KeepDays}}{{else}}14{{end}}"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" type="submit">Сохранить</button>
<button class="btn" type="button" id="bk-test" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить доступ</button>
<button class="btn" type="button" id="bk-run" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Сделать копию сейчас</button>
<button class="btn" type="button" id="bk-list" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Копии на хранилище</button>
</div>
<div id="bk-out" class="muted" style="font-size:13px"></div>
</form>
<script>
(function(){
var f=document.getElementById('bk-form'), out=document.getElementById('bk-out');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
function post(url, withForm){
var p=new URLSearchParams();
if(withForm){ ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); }
return fetch(url,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()});
}
document.getElementById('bk-test').onclick=function(){ out.textContent='Проверяю доступ…';
post('/admin/setup/backup/test',true).then(function(d){ out.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+esc(d.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; }); };
document.getElementById('bk-run').onclick=function(){ out.textContent='Запускаю копию…';
post('/admin/setup/backup/run',false).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
out.textContent='Копия делается (обычно до минуты)…'; var t0=Date.now();
var iv=setInterval(function(){ fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
var b=s.backup; if(b && Date.parse(b.at)>=t0-60000){ clearInterval(iv); out.innerHTML=b.ok?'<span style="color:var(--ok)">✓ копия готова: '+esc(b.dir)+', '+esc(b.msg)+'</span>':'<span style="color:var(--err)">✗ '+esc(b.msg)+'</span>'; }
if(Date.now()-t0>600000) clearInterval(iv); }); },3000); }); };
document.getElementById('bk-list').onclick=function(){ out.textContent='Читаю список…';
post('/admin/setup/backup/list',true).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
if(!d.list||!d.list.length){ out.textContent='Копий на хранилище нет.'; return; }
out.innerHTML='Копий: <b>'+d.list.length+'</b>. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+
'<div style="display:grid;gap:4px;margin-top:6px">'+d.list.slice(0,60).map(function(n){
return '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><code>'+esc(nice(n))+'</code><button type="button" class="btn bk-verify" data-name="'+esc(n)+'" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить</button><span class="bk-vres" style="font-size:12px"></span></div>';
}).join('')+'</div>';
out.querySelectorAll('.bk-verify').forEach(function(btn){ btn.onclick=function(){
var res=btn.nextElementSibling; res.textContent='скачиваю и проверяю…'; btn.disabled=true;
var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(k){ var v=f[k].value.trim(); if(v) p.set(k,v); }); p.set('name',btn.dataset.name);
fetch('/admin/setup/backup/verify',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
btn.disabled=false; res.innerHTML=v.ok?'<span style="color:var(--ok)">✓ '+esc(v.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(v.error)+'</span>'; });
}; }); }); };
})();
</script>
</div>
</section>
<section class="settings-section" id="sec-license">
<div class="card">
<h2>
@@ -50,6 +50,62 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{/* ============= ШАГ 1: Лицензия ============= */}}
{{if eq .Step 1}}
<div class="card" id="wiz-restore" style="border-style:dashed">
<details>
<summary style="cursor:pointer;font-weight:600">Переустановка сервера? Восстановить из резервной копии</summary>
<p class="muted" style="font-size:13px;margin:8px 0">Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.</p>
<form id="rs-form" style="display:grid;gap:8px;max-width:640px" onsubmit="return false">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
<label>Хранилище (SMB)<input name="share" placeholder="//10.10.10.11/Backup" value="{{.Settings.Backup.Share}}"></label>
<label>Папка<input name="dir" placeholder="BJ_Backup/bj" value="{{.Settings.Backup.Dir}}"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label>Логин хранилища<input name="user" autocomplete="off" value="{{.Settings.Backup.User}}"></label>
<label>Пароль<input name="password" type="password" autocomplete="new-password"></label>
</div>
<div><button class="btn" type="button" id="rs-list">Показать копии</button></div>
<div id="rs-pick" style="display:none;gap:8px;align-items:flex-end;flex-wrap:wrap">
<label>Копия<select name="name" id="rs-name" style="min-width:220px"></select></label>
<button class="btn" type="button" id="rs-go" style="background:var(--err);border-color:var(--err)">Восстановить</button>
</div>
<div id="rs-out" class="muted" style="font-size:13px"></div>
</form>
<script>
(function(){
var f=document.getElementById('rs-form'), out=document.getElementById('rs-out'), pick=document.getElementById('rs-pick'), sel=document.getElementById('rs-name');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function params(){ var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); return p; }
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
document.getElementById('rs-list').onclick=function(){
out.textContent='Читаю список копий…'; pick.style.display='none';
fetch('/admin/setup/backup/list',{method:'POST',body:params(),cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
if(!d.list||!d.list.length){ out.textContent='На хранилище копий нет.'; return; }
sel.innerHTML=d.list.map(function(n){return '<option value="'+esc(n)+'">'+esc(nice(n))+'</option>'}).join('');
pick.style.display='flex'; out.textContent='Найдено копий: '+d.list.length+'. Выберите копию — обычно самую свежую.';
}).catch(function(){ out.textContent='Не удалось получить список.'; });
};
document.getElementById('rs-go').onclick=function(){
if(!confirm('Восстановить копию от '+nice(sel.value)+'? Текущие настройки и данные этого сервера будут заменены, bj и шлюз НРД перезапустятся.')) return;
var p=params(); p.set('name', sel.value); var t0=Date.now();
out.textContent='Запускаю восстановление…';
fetch('/admin/setup/backup/restore',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
var iv=setInterval(function(){
fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
var r=s.restore; if(!r) return;
out.textContent='Восстановление: '+r.msg;
if(r.stage==='done'){ clearInterval(iv); out.innerHTML='<span style="color:var(--ok)">✓ '+esc(r.msg)+'. Сейчас откроется вход — войдите учётной записью из восстановленной копии.</span>'; setTimeout(function(){ location.href='/admin/'; }, 4000); }
if(r.stage==='error'){ clearInterval(iv); out.innerHTML='<span style="color:var(--err)">✗ '+esc(r.msg)+'</span>'; }
}).catch(function(){ out.textContent='bj перезапускается, жду…'; });
if(Date.now()-t0>900000) clearInterval(iv);
}, 3000);
});
};
})();
</script>
</details>
</div>
<div class="card">
<h2><span class="dot {{if .Done.License}}ok{{else}}err{{end}}"></span>Шаг 1. Лицензия</h2>
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>